Hammerspoonを使ってエスケープキーで英数モードへ・コロンとセミコロンを入れ替える

Hammerspoonは、macOSでキー入力やウィンドウ操作をLuaスクリプティングでカスタマイズできる便利なアプリです。 特にキーマッピングを変更するhs.hotkey.bindは便利なAPIですが、デフォルトの処理も行われなくなるのでたまに困る場面があります。

例えば、escapeキーを押した時に、普通のエスケープ処理に加えてIMEを英数モードにするみたいなことをしたいときにはhs.hotkey.bindは使えません。 正確には、一時的にbindを解除しておいてデフォルト処理のためのイベントを発火して、タイマーで再度bindを有効化するみたいな処理を書けば使えなくはないのですが、これはあまり良い設定方法ではありません。 こういう場面ではhs.eventtap.newを使ってキーが押されたときに処理を差し込むことで綺麗に設定できます。

switchToEisuOnEscape = hs.eventtap.new({hs.eventtap.event.types.keyDown}, function(e)
  if hs.keycodes.map[e:getKeyCode()] == 'escape' then
    hs.eventtap.keyStroke({}, 'eisu', 0)
  end
end):start()

switchToEisuOnEscapeグローバル変数で参照されていませんが、こうしておかないとGCが走ってeventtapが動かなくなるので、こういうものをグローバル変数に入れておくのはHammerspoonの設定あるあるです (参考)。

ここからが本題ですが、皆さんはコロンとセミコロンを入れ替えていますか? この記事に辿り着いた皆さんはVimを使う英字キーボードユーザーだと思うのですが、当然なんらかの方法でコロンとセミコロンは入れ替えていますよね? そういう場面でもhs.hotkey.bindよりもhs.eventtap.newを使う方が良いでしょう。

swapColonAndSemicolon = hs.eventtap.new({hs.eventtap.event.types.keyDown}, function(e)
  if hs.keycodes.map[e:getKeyCode()] == ';' then
    remappingColonAndSemicolon = not remappingColonAndSemicolon
    if remappingColonAndSemicolon then
      hs.eventtap.event.newKeyEvent(hs.keycodes.map.shift, not e:getFlags().shift):post()
      hs.eventtap.event.newKeyEvent(';', true):post()
      return true
    end
  end
end):start()

コロンまたはセミコロンが押されたときにシフトキーの状態をトグルして、再度セミコロンキーをkey-downするイベントを発火します (hs.eventtap.event.newKeyEventの第二引数がkey-downかkey-upかを表すbooleanです)。 ここで発火したイベントに対してもeventtapが反応してしまうので、booleanのグローバル変数で処理が無限再帰してしまわないようにしています。 trueを返してイベントの伝搬を止めているのも重要なポイントです。 この設定ならキーリピート時にもきちんと動作してとても便利です。

私は最近までhs.hotkey.bindを使ってコロンとセミコロンを入れ替えていたのですが、いつの間にかリマップの処理に時間がかかるようになってしまい (:qと押したつもりがq:になるなど)、またキーリピート時にも思うように動作しなくなってしまいました。 コロンを打ったあとは気持ち少し待つみたいにしてしばらくは頑張っていたのですが、設定をちゃんと見直したところhs.eventtap.newを使う方が良いということがわかりました。 Hammerspoonを使っていて困っている方にお役に立てると幸いです。

2022年を振り返って

今年はサイボウズに転職して一年が経ち、インフラ基盤の移行プロジェクトに参加したり、改善プロジェクトを立ち上げてリードしたりと開発チームに貢献できるようになりました。 エンジニア主導でチームを組んで改善を行う制度が導入されてから様々な改善活動が活発に行われており、コードの品質が上がっているのを感じます。 組織体制も領域専任のチームへと移行し、大きく変化した一年でした。これらの活動を基盤として来年はさらに開発速度を加速させていきたいですね。 また、11月にはJJUG CCC 2022 Fallのスポンサーセッションに登壇しました。 チームの開発体制の改革や改善プロジェクトの話についてまとめて発表しました。 itchyny.hatenablog.com

OSS活動は粛々とgojqの改善をしていました。今年に新しく作ったのはjson2yamlくらいでしたね。 あとはjqに関連してjackson-jqxqにcontributeしたり、Go言語にproposalを通したりしていました。 夏頃に何を思ったかsedについて勉強したことで、かなり詳しくなりました。 s(置換)とd(削除)以外にも様々なコマンドで文字列を処理できるので、なるほどストリーム"エディタ"と名乗るだけのことはありますね。 better grepとしてsedを使いクラスの継承関係を抽出したり、ソースコードを書き換えたりするのに使っています。 /pattern1/{:l; /pattern2/!{N; bl}; ...}ソースコードを (例えばクラスや関数定義の単位で) 絞り込んでから、更にパターンで絞り込んで置換したり、Fコマンドでファイル名と一緒に表示したりするというのをよくやっています。

今年のアニメの中では『SPY×FAMILY』が圧倒的に良かったですね。あとは『リコリス・リコイル』や『平家物語』などもなかなか良かったです。ドラマは『PICU』『silent』『祈りのカルテ』『ファーストペンギン!』『エルピス』『石子と羽男』『ユニコーンに乗って』『アトムの童』『元彼の遺言状』『六本木クラス』『ミステリと言う勿れ』と本当に色々と見ていました。面白いドラマが多かったですね。映画は『すずめの戸締まり』が良かったです。

今年は私生活の面でも結婚という大きな転機がありました。 仕事の面でも期待されているようなので、バランスをとりながらしっかりとやっていきましょう。 来年も良い一年となりますように。

ロイド・フォージャー「たとえ槍が降ろうと、隕石が落ちてこようと、僕は生涯をかけて彼女を守り抜きます。」

SPY×FAMILY MISSION:8 対秘密警察偽装作戦

Go言語に出したプロポーザルが通った:{bytes,strings}.ContainsFuncの追加

今年の夏にGo言語に以下のようなプロポーザルを出していたのですが、それが先ほど承認されました。標準パッケージの関数追加になります。 proposal: bytes, strings: add ContainsFunc · Issue #54386 · golang/go · GitHub

Go言語のstringsパッケージbytesパッケージには、文字列から文字や部分文字列を探す関数がいくつかあります。 探す文字の位置を返す関数、最後から探す関数、そういう文字が含まれるかどうかを返す関数を表にまとめると、次のようになります。

Find what? Index* LastIndex* Contains*
substr string Index(s, substr string) int LastIndex(s, substr string) int Contains(s, substr string) bool
chars string IndexAny(s, chars string) int LastIndexAny(s, chars string) int ContainsAny(s, chars string) bool
c byte IndexByte(s string, c byte) int LastIndexByte(s string, c byte) int --
r rune IndexRune(s string, r rune) int -- ContainsRune(s string, r rune) bool
f func(rune) bool IndexFunc(s string, f func(rune) bool) int LastIndexFunc(s string, f func(rune) bool) int Proposal #54386

私がプロポーザルで出したのは、関数で指定した条件に当てはまる文字が含まれるかどうかというContainsFunc関数です。 文字列の中から関数で条件を指定して当てはまる文字を探すためには、IndexFuncの返り値から判定しなくてはいけませんでした。

hasControlRune := strings.IndexFunc(target, unicode.IsControl) >= 0

これで良いという話ではありますが、いつもいつも数字と比較するのは面倒ですし、読みやすくもありません。 それに >= 0!= -1 かといった書き方の好みも分かれてしまい、どちらが良いかというナンセンスな議論を生んでしまいます。 strings.ContainsFuncがあれば、コードの意図がより分かりやすくなるのではないでしょうか。

hasControlRune := strings.ContainsFunc(target, unicode.IsControl)

最初はgojqを書いている時に、上のような判定コードでstrings.ContainsFuncが欲しくなったというのがきっかけでした。 この関数があるとどれくらい嬉しいかということを主張するために、semgrepでGo言語のリポジトリをスキャンしてみたのですが、strings.IndexFuncbytes.IndexFuncを使っている9箇所のうち、6箇所がContainsFuncで置き換えられることが分かりました。 過半数が文字が含まれているかの判定で使われていて、位置自体を欲しいケースは半分以下ということですね。 これはContainsFunc追加をサポートする良い材料だと思い、プロポーザルに書いてみました(本来はもっと大きなコードベースで調査した方がいいのでしょう)。

まずslices.ContainsFuncという別のプロポーザルが承認され、これと関連している私のプロポーザルも先ほど承認されました。 Go言語にプロポーザルを出したのは初めてでしたが、特に白熱した議論が繰り広げられることもなく、すんなり承認されましたね。 自分の提案がきっかけで実装される機能が世に出るのは楽しみです。 \広告です/

JJUG CCC 2022 Fallで『組織と技術の両輪で開発を加速させるkintoneチームの取り組み』という発表をしました

本日開催されたJJUG CCC 2022 Fallのスポンサーセッションに登壇いたしました。

speakerdeck.com

久しぶり(五年ぶり)の登壇というのと録画発表ということもあって色々と大変でしたが、無事終わってほっとしています。 他の方の発表もとても勉強になりました。個人的ベストトーク賞は『バーチャルスレッド詳細』です。とてもユニークで勉強になる良い発表でした。

ご視聴いただいた方、運営スタッフのみなさま、ありがとうございました。

Go言語でJSONをYAMLに変換するコマンド json2yaml を作りました!

JSONYAMLに変換するコマンド json2yaml を、Go言語で作ってみました。 他の言語も含めると同じようなコマンドラインツールは無数に作られていますが、 Goのライブラリとして組み込みたかったというのが最初のモチベーションです。 また、JSONをストリーミング的にYAMLに変換できるのかという疑問を以前から抱いていて、ここに答えを得たかったというところもありました。

github.com

go install github.com/itchyny/json2yaml/cmd/json2yaml@latest

Homebrewでもインストールできます。

brew install itchyny/tap/json2yaml

JSONファイルを引数にしてYAMLを出力したり、

 $ cat sample.json
{
  "Sample": "JSON"
}

 $ json2yaml sample.json
Sample: JSON

標準入力を変換したりできます。

 $ curl -sSf "https://httpbin.org/json" | json2yaml
slideshow:
  author: Yours Truly
  date: date of publication
  slides:
    - title: Wake up to WonderWidgets!
      type: all
    - items:
        - Why <em>WonderWidgets</em> are great
        - Who <em>buys</em> WonderWidgets
      title: Overview
      type: all
  title: Sample Slide Show

REST APIの結果をlessで見たり、Jsonnetのビルド結果をKustomizeに持ってきたいときなどに便利そうですね。

ライブラリとしてはio.Writerio.Readerを受け取る関数Convertを提供していて、任意の入力から出力に変換できるようになっています。 もし標準入力から読み込んで変換結果を標準出力に出すだけならば、json2yaml.Convert(os.Stdout, os.Stdin) だけで実装できるようになっています。 引数の順序はio.CopyTransformerに倣っています。 Goの標準パッケージのみに依存しているので、他のツールにも組み込みやすいと思います。

元々はgojoコマンドYAML出力を対応してほしいという要望を受けたのが、このツールを作ったきっかけでした。 gojoコマンドはjoコマンドのGo実装で、JSONを簡単に組み立て出力できるツールです。

 $ gojo -p res[foo][][id]=10 res[foo][][id]=20 res[cnt]=2
{
  "res": {
    "foo": [
      {
        "id": 10
      },
      {
        "id": 20
      }
    ],
    "cnt": 2
  }
}

 $ gojo -y res[foo][][id]=10 res[foo][][id]=20 res[cnt]=2
res:
  foo:
    - id: 10
    - id: 20
  cnt: 2

要望を上げていただいた方は、gojoの出力をgojq --yaml-inputで変換しているとおっしゃっていて、自分のツールを組み合わせて使っている方からこのようにフィードバックが来るのはOSSをやっていてとても嬉しいですね。

itchyny.hatenablog.com itchyny.hatenablog.com \広告です/

さて、json2yamlの実装の話をしましょう。 YAMLJSONのスーパーセット、つまり全てのJSONYAMLでもあるので、入力をそのまま出力してもJSONからYAMLの変換と言えます。 しかしYAMLフォーマットとして我々が想像するのは、マッピングやシーケンスの括弧、文字列のクォートをできる限り使わずに書くフォーマットのことでしょう。

sample:
  string: Hello, world!
  sequence:
    - 0
    - null
    - boolean: true
    - string: This is a string!
  nested:
    mapping:
      and: |-
        #!/bin/sh
        echo "This is"
        echo "a multi-line"
        echo "string!"
    sequence:
      - - - - Deeply nested sequence!
  empty:
    - mapping: {}
    - sequence: []

こういう人間に優しいYAMLの出力を得るには、どのような実装が必要でしょうか。 まず、インデントのレベルの管理が必要ですね。 そして、マッピングやシーケンスの状態管理も必要になります。 これらのコレクションを閉じた次がマッピングのキーなのかシーケンスの要素なのか判別するために、スタックで管理します。 また、空マッピング {} と空シーケンス [] は特殊な処理が必要です

文字列をクォートせずにプレインスタイルで書いて良いかというのはなかなか複雑なルールが必要になります。 YAMLはさまざまな記号に意味がありますので、それらを含む場合はクォートが必要になります。 json2yamlでは、クォートが必要な文字列のパターンを正規表現にして判定するように実装しました。

- ""                   # 空文字はnullになるのでクォートが必要
- "~"                  # ~はnullになるのでクォートが必要
- ~~                   # ~~ならばクォートは不要
- "-"                  # -は[null]になるのでクォートが必要
- --                   # --ならばクォートは不要
- "false"              # booleanになるのでクォートが必要
- "y"                  # booleanになるのでクォートが必要 (YAML 1.1)
- "42.195"             # 数値になるのでクォートが必要
- 42.195.              # 数値にはならないのでクォートは不要
- ".nan"               # NaNになるのでクォートが必要
- "12:59:59"           # 60進数整数になるのでクォートが必要 (YAML 1.1)
- 12:59:60             # 60進数整数にはならないのでクォートは不要
- "2022-08-29"         # 日付になるのでクォートが必要
- "a #b"               # " #"以降はコメントになるのでクォートが必要
- a#b#c                # '#'の前が空白文字ではないのでクォートは不要
- "&test"              # '&'で始まるとアンカーになるのでクォートが必要
- "*test"              # '*'で始まるとエイリアスになるのでクォートが必要
- "* test"             # '*'の直後が空白文字の場合はエイリアスにはなりえないが、
                       # プレインスタイルは'*'で始まってはいけないのでクォートが必要
- x * y                # '*'から始まるわけではないのでクォートは不要
- "!test"              # '!'で始まるとタグになるのでクォートが必要
- "a: 0"               # ": "を含むとマッピングになるのでクォートが必要
- a:0                  # ':'の直後が空白文字ではないのでクォートは不要
- https://example.com/ # ':'の直後が空白文字ではないのでクォートは不要
- "\"quote"            # '"'で始まる場合はクォートが必要
- q"u"o"t"e"           # '"'が文字列の途中や最後の場合はクォートは不要
- "[null]"             # '['で始まるとフロースタイルのシーケンスになるのでクォートが必要
- "-100"               # 数字になるのでクォートが必要
- -100E                # 文字列全体が数字にならないのでクォートは不要
- "- 100E"             # '-'の後に空白文字があるとシーケンスになるのでクォートが必要
- " spaces "           # 前後の空白文字があるとクォートが必要
- "\uFEFFabc"          # BOMを含むのでクォートが必要
- "\uFFFFabc"          # Noncharacterを含むのでクォートが必要
---                    # document separator
"---"                  # document separatorと一致するのでクォートが必要
---                    # document separator
- ---                  # document separatorと一致するがシーケンスの要素なのでクォートは不要

JSONの文字列はYAMLの文字列でもあるので、クォートするときは基本的にJSONと同じエスケープをすれば大丈夫です。 ただし、YAMLパーサーにはクォート内のNoncharacterを解釈できないものがあるので、仕様からするとパーサーのバグですが (パーサーを名乗るなら仕様に沿って実装して欲しい)、json2yamlはNoncharacterもエスケープシーケンスで出力します。 文字列を扱うプログラムはNoncharacterが渡されたときの挙動に気をつけたいですね。

YAMLの複数行文字列は、制御コード・BOM・Noncharacter以外は基本的になんでも使えます。

- |
  This is
  a multi-line
  string.

- |-
  Chomp final
  line breaks.

- |+
  Keep final
  line breaks.

- ? |
    mapping
    key
  : |
    mapping
    value

- |2
      Leading white space
  can be represented
  by indentation indicator.

| |- |+は、文字列最後の改行の扱い方が変わります。 あまり遭遇することはありませんが、マッピングのキーにも複数行文字列を使えます。 文字列がスペースから始まる場合は、インデントの数を指定しないといけません (指定しないと、最初の行がインデントの基準になります)。 ただ、この構文をサポートしていないYAMLパーサーがあるので (パーサーを名乗るなら以下略)、 json2yamlは空白文字から始まる複数行文字列はクォートして出力しています。

json2yamlはJSON全体をメモリーに読み込まず、JSONの字句 (トークン) ごとにYAMLに変換していきます。 この実装により、map[string]any[]anyのメモリー確保を避けられます。 あまり現実的な例ではありませんが、以下のような巨大なシーケンスでも処理できます。

 $ (echo "["; yes "0," | head -n 100000000; echo "0]") | json2yaml
- 0
- 0
- 0
- 0
- 0
- 0
- 0
^C

ストリーミング処理だとマッピングのキー重複の扱いが気になる人もいるでしょう。 JSONではキーの重複は許されていますが (非推奨ではあります)、YAMLでは明確に許されていません。 ただしこれは仕様上の話であって、実際にはYAMLでもキー重複をエラーにしないパーサーはあるようです。 json2yamlはキー重複をエラーにしたり上書きにしたりはせずに、重複のまま出力しています。

ストリーミング処理ならば速そうということでパフォーマンスが気になるところだと思いますが、ファイルサイズに依存してだいぶ結果が変わるので、あくまで一例として見ていただければと思います。 gojq --yaml-outputPython版のjson2yamlNode版のjson2yaml、そしてyqを比較しました。

 $ export file=/tmp/package-lock.json
 $ file "$file"
/tmp/package-lock.json: JSON data
 $ stat -f %z "$file"
135843
 $ hyperfine --warmup 10 'json2yaml "$file"' 'gojq --yaml-output . "$file"' 'py/json2yaml "$file"' 'js/json2yaml "$file"' 'yq "$file"'
Benchmark 1: json2yaml "$file"
  Time (mean ± σ):       8.6 ms ±   0.3 ms    [User: 7.7 ms, System: 0.6 ms]
  Range (min … max):     7.9 ms …   9.4 ms    276 runs

Benchmark 2: gojq --yaml-output . "$file"
  Time (mean ± σ):       9.4 ms ±   0.4 ms    [User: 10.2 ms, System: 2.3 ms]
  Range (min … max):     8.5 ms …  11.1 ms    260 runs

Benchmark 3: py/json2yaml "$file"
  Time (mean ± σ):     103.8 ms ±   0.5 ms    [User: 98.1 ms, System: 4.4 ms]
  Range (min … max):   102.9 ms … 104.9 ms    28 runs

Benchmark 4: js/json2yaml "$file"
  Time (mean ± σ):      43.4 ms ±   0.7 ms    [User: 37.3 ms, System: 4.9 ms]
  Range (min … max):    42.0 ms …  44.7 ms    64 runs

Benchmark 5: yq "$file"
  Time (mean ± σ):     110.1 ms ±   1.7 ms    [User: 365.9 ms, System: 32.4 ms]
  Range (min … max):   106.2 ms … 113.0 ms    26 runs

Summary
  'json2yaml "$file"' ran
    1.09 ± 0.06 times faster than 'gojq --yaml-output . "$file"'
    5.06 ± 0.17 times faster than 'js/json2yaml "$file"'
   12.10 ± 0.37 times faster than 'py/json2yaml "$file"'
   12.83 ± 0.43 times faster than 'yq "$file"'

繰り返しになりますがこれは一例であって、必ずこの順位になるということではありません。 特に、Node版のjson2yamlはパフォーマンスが良くて、JSONファイルが大きくなるほど優秀でした (JSON.parseが圧倒的に速いんだと思います… 500MBを超えると負けました)。 ただし、マッピングのキーが*&から始まるとYAMLとして不正な出力になったり、キーが#から始まると出力行がコメントになったり、60文字を超える文字列の場合に先頭と末尾に改行が入ってしまったりしていて (JSONに戻すと値が変わる)、とても残念な出力でした。 yqはGoで書かれていますが、なぜここまで遅いのかはよく分かりませんでした。

YAMLはフロースタイルとブロックスタイルという文脈に応じて使える文字が変わるため、仕様の構文定義が複雑で読みとくのに骨が折れました。 また、仕様のゆるいYAML 1.1を実装しているパーサーもこの世にはまだあるため、json2yamlでは厳しめに文字列をクォート出力しています。 手元の開発マシンにはわりとたくさんJSONファイルがある (主にnode_modules) ので、これを使って変換処理をテストしました。 BOMがついていたり、C1制御コードが含まれていたり、だいぶ良いテストデータになりました。

Go版 json2yaml コマンドとライブラリ、作ってまだ間もないツールですが、完成度はわりと高いと思うので、ぜひ使ってみてください。 github.com