せめてリポジトリの各ディレクトリの概要説明だけでも欲しい思ったので dirmap というツールを作ってみた

既存の開発に参加するときや、0->1の開発をしているとき、いつも「せめてリポジトリの各ディレクトリの概要説明だけでも欲しい」と思っていました。

既存のプロジェクトに参加するときは「プロジェクトの理解をする側」、0->1のプロジェクトで開発をしているときは「説明をする側の立場」で、です。

Ruby on Railsのような基本のディレクトリレイアウト決まっていてもそのプロジェクトの独自性がでてきますし、Goのようにスタンダードなレイアウトがないのであればなおさら初見ではわかりません。

「じゃあREADME.mdにでも書いておけばいい」というのはその通りです。

ただ、概要説明であっても一度書いたら終わりではなく、更新は必要になります。特に0->1のプロジェクトの初期ではディレクトリレイアウトすら途中で変わるということはままあります。

(ここらへんは「継続的ドキュメンテーション」として私の興味のある分野です。私のSpeaker Deckのドキュメント系の発表は大体が継続的ドキュメンテーションにつながっています)

そこで、できるだけ簡単に各ディレクトリの概要説明を実現するために dirmap というツールを作ってみました。

github.com

dirmap

dirmapはディレクトリを再帰的に走査して各ディレクトリの「概要を説明していそうな箇所の文字列」を取得し「各ディレクトリの概要説明=ディレクトリマップ」を生成するツールです。

たとえば dirmap のリポジトリルートで dirmap generate を実行すると以下のような tree に近い出力を得ることができます。

$ dirmap generate
.
├── .github/
│   └── workflows/
├── cmd/ ... Commands.
├── config/ ... Configuration file.
├── matcher/ ... Implementation to find the string that will be the overview from the code or Markdown.
├── output/ ... Output format of the directory map.
├── scanner/ ... Implementation of scanning the target directory and its overview from the file system based on the configuration.
├── scripts/ ... scripts for Dockerfile.
└── version/ ... Version.

この Commands.scripts for Dockerfile. のような概要っぽい文字列はどこから取得しているかというと、「そのディレクトリにあるMarkdownファイルの見出しではない最初のパラグラフ」や「そのディレクトリにあるGoのソースコードに書かれているgodocのpackages Overviewにあたる部分」などです。

このように「そのディレクトリにあるファイルからそのディレクトリの説明となる文章を抽出すれば、各ディレクトリの概要説明の管理も最小限になるだろう」という算段です。

「概要っぽい文字列」の取得ルールは設定ファイルを渡すことで変更も可能です。built-inな markdown markdownHeading godoc 以外にも正規表現マッチもサポートしています。

設定ファイルは dirmap init で生成可能です。書かれている設定は dirmap のデフォルト設定(設定ファイルなしの場合の挙動と同じ)になります。

$ dirmap init
Create .dirmap.yml
# $ cat .dirmap.yml
targets:
- file: .dirmap.md
  matcher: markdown
- file: README.md
  matcher: markdown
- file: doc.go
  matcher: godoc
- file: "*.go"
  matcher: godoc

ディレクトリの概要は targets: に指定した走査条件に最初にマッチした文字列になります。

dirmap generate で出力できるフォーマットは今の所「treeライクなASCIIの出力」と「Markdown(GFM)のテーブル形式」の2つです(以下はテーブル形式)。

$ dirmap generate -t table
| Directory | Overview |
| --- | --- |
| .github/ |  |
| .github/workflows/ |  |
| cmd/ | Commands. ( [ref](cmd/doc.go) ) |
| config/ | Configuration file ( [ref](config/config.go) ) |
| matcher/ | Implementation to find the string that will be the overview from the code or Markdown. ( [ref](matcher/matcher.go) ) |
| output/ | Output format of the directory map ( [ref](output/output.go) ) |
| scanner/ | Implementation of scanning the target directory and its overview from the file system based on the configuration. ( [ref](scanner/scanner.go) ) |
| scripts/ | Scripts for Dockerfile ( [ref](scripts/.dirmap.md) ) |
| version/ | Version ( [ref](version/version.go) ) |

とりあえずdirmapを使って各ディレクトリの概要説明をいれつつ、新しくプロジェクトに入るメンバーのために開発者用ドキュメントをメンテナンスしてみようと思います。

Goのプロジェクトならgodocのpackage overviewの箇所だけ書いておけばいいだけなので楽です(他の言語も良いドキュメントフォーマットが存在するなら対応したいと思っています。ぜひ教えてください)。

これだけで開発開始までのオーバーヘッドが大幅に減るとは思ってはいませんが、継続的ドキュメンテーションの1つとして試してみようと思います。

ディレクトリ内のPDFとEPUBをタイトル付きでリストアップする ebk ls を作った

私は買った電子書籍を1つのディレクトリに保存しているのですが、ファイル名はダウンロードしたときのそのままにしていることが多いので、良く「あの書籍はどのファイルだっけ?」が発生していました。

電子書籍リーダーで管理すればいいとは思うのですが、それを整備することすらしていない有様です。

とりあえず「あの書籍はどのファイルだっけ?」や「あの書籍って買ってたっけ?」をなんとか解消したいと思っていました。

PDFファイルやEPUBファイルの電子書籍のタイトルだけでも一覧表示できれば上記が解消すると思っていたので、重い腰をあげて作りました。

github.com

ebk

上に貼ったツイート以上の情報はないのですが、 ebk ls [DIR] を実行するとディレクトリ内のPDFとEPUBのタイトルとファイル名をデリミタ(デフォルトは :)で区切って出力します。

$ ebk ls ~/path/to/books/ | grep oreilly
NFS & NIS 第2版:oreilly-4-87311-078-5e.pdf
ウェブオペレーション:oreilly-978-4-87311-493-4e.pdf
SQLアンチパターン:oreilly-978-4-87311-589-4e.epub
[...]
Go言語による並行処理:oreilly-978-4-87311-846-8e.epub
入門 監視:oreilly-978-4-87311-864-2e.epub
入門 Prometheus:oreilly-978-4-87311-877-2e.epub
みんなでアジャイル:oreilly-978-4-87311-909-0e.epub
ユニコーン企業のひみつ:oreilly-978-4-87311-946-5e.epub

使い方としては peco などのツールと合わせて使うことを想定していて、macOSだと以下のようなコマンドで、選択した電子書籍電子書籍リーダーで開くことができます。

$ open $(ebk ls /path/to/books/ --with-path -d '\t' | peco | awk -F '\t' '{print $2}')

f:id:k1LoW:20211122072335p:plain

タイトルを取得できなかった場合は ??? で表示されます。

というわけで

個人的にはもう満足していて ebk ls 以外のコマンドは作らなそうですが、また電子書籍メタデータで何かしたくなったら ebk に追加しようと思います。

私は、ebk のおかげで早速間違って二重購入するのを防げました。作った甲斐がありました。もっとちゃんと管理しろというお話ですが。

もし同じように「あの書籍はどのファイルだっけ?」に困っている方がいましたら使ってみてください。

go-githubのClientをいい感じに組み立ててインスタンスを生成するだけのgo-github-clientを作った

GitHubGitHub Actionsが好きで、いろいろツールを作ったりするのですが、毎回毎回go-githubのClientのインスタンス生成のために何行かコードを書いています。

最初の頃は GITHUB_TOKEN のことだけを考えていていたのが、その後GitHub Enterpriseのエンドポイントも考えるようになり、GitHub Actionsの登場からはActions上の環境変数をうまく使いたくもなり、GitHub CLI extensionがでてきてからは、GH_* という環境変数も考慮する必要がでてきています。

その都度過去のツールのコードをまるっとコピペしつつもgo-githubのClientインスタンス生成コードは変わってきています。

また、go-githubはセマンティックバージョニングを採用している(?)のか、既にv39まできています。 go-githubのClientを引数に受け取るような外部パッケージがあったとき、その外部パッケージが採用しているgo-githubのメジャーバージョンに合わせないといけないのも苦でした。

毎回コピペコードを書いたり、過去ツールの実装を直したりするのも嫌になってきたので、go-github-clientとしてコード片をパッケージ化しました。

github.com

go-github-client

使い方は go-github のメジャーバージョンに合わせて go-github-client/[VERSION]/factory をimportして使うだけです。

package main

import (
    "context"
    "fmt"

    "github.com/k1LoW/go-github-client/v39/factory"
)

func main() {
    ctx := context.Background()
    c, _ := factory.NewGithubClient()
    u, _, _ := c.Users.Get(ctx, "k1LoW")
    fmt.Printf("%s\n", u.GetLocation())
}

factory.NewGithubClient() 内で、環境変数からトークンやエンドポイントの解決をし生成したClientインスタンスを返します。

また、Functional Option Patternを採用しているので、外部からトークンやエンドポイントなどを差し込むことも可能です。

例えば、go-githubをモック化したい場合は、 *http.Client を外部から差し込むことで解決します。

package main

import (
    "context"
    "testing"

    "github.com/google/go-github/v39/github"
    "github.com/k1LoW/go-github-client/v39/factory"
    "github.com/migueleliasweb/go-github-mock/src/mock"
)

func TestUsingMock(t *testing.T) {
    mockedHTTPClient := mock.NewMockedHTTPClient(
        mock.WithRequestMatch(
            mock.GetUsersByUsername,
            github.User{
                Name: github.String("foobar"),
            },
        ),
    )
    c, err := factory.NewGithubClient(factory.HTTPClient(mockedHTTPClient))
    if err != nil {
        t.Fatal(err)
    }

    ctx := context.Background()
    user, _, err := c.Users.Get(ctx, "myuser")
    if err != nil {
        t.Fatal(err)
    }
    got := user.GetName()
    if want := "foobar"; got != want {
        t.Errorf("got %v\nwant %v", got, want)
    }
}

バージョニング

バージョニングは少し特殊です。

このgo-github-clientパッケージ自体がgo-githubのClientを生成するだけのものなので、基本的にgo-githubバージョンと合わせてタグやディレクトリを切っています。

ただ、go-github-client自体の機能追加もあるので、それにはパッチバージョンに加算することで対応します。

表で表すと以下のような感じです。

バージョン 構成
Major google/go-github のmajorバージョン
Minor google/go-github のminorバージョン
Patch google/go-github のpatchバージョンにk1LoW/go-github-clientの更新を加算

まあちょっとひどいですが、利便性を考えて割り切ります。

というわけで

ただのコード片ですが、これからもGitHub APIを叩くツールや実装は作りそうなので、個人的にはこれを使っていこうかと思っています。

そういえば昔もawsecretsというgemを作ったりしていて、成長していないなーなどと思いました。

Monorepoなリポジトリでgo.modがネストされた位置にあるときのgoplsの設定

$ gopls version
golang.org/x/tools/gopls v0.7.3
    golang.org/x/tools/gopls@v0.7.3 h1:Lru57ht8vtDMouRskFC085VAjBAZRAISd/lwvwOOV0Q=

Monorepoなリポジトリ内のサブプロジェクトの開発でEmacs + goplsが動かないなーと思っていて *lsp-log* bufferをみると以下のようなエラーが出ていました。

 errors loading workspace: gopls requires a module at the root of your workspace.
You can work with multiple modules by opening each one as a workspace folder.
Improvements to this workflow will be coming soon, and you can learn more here:
https://github.com/golang/tools/blob/master/gopls/doc/workspace.md.
  snapshot=0
  directory=file:///Users/k1low/src/path/to/monorepo

https://github.com/golang/tools/blob/master/gopls/doc/workspace.md によると、通常の設定+Multiple modules構成*1では動かないらしいとのこと。

goplsの experimentalWorkspaceModule の設定を有効にすることで、The workspace moduleという機能を有効になりMultiple modules構成に対応ができるとのこと。

手元の環境では上記を設定とりあえず有効にしてみたらちゃんと補完されました。

Emacsだと以下のような感じ。

(lsp-register-custom-settings
 '(
;; [...]
   ("gopls.experimentalWorkspaceModule" t t)
;; [...]
   ))

*1:私は、「プロジェクトルート以外にgo.modがある構成」と理解しました

GitHub上にあるリポジトリに対してAPIを通じてgit grepライクに走査できるツール gh-grep (gh grep) を作った

git grep 便利ですよね。

私は git grepgit gsub は本当によく使います。

ところで git grep はローカルリポジトリがないと実行できません。

ローカルにリポジトリがなければ git clone して、 git grep すればいいのですが、もう少し簡単にgrepするために gh-grep を作りました。

github.com

gh-grep

gh-grepGitHub APIを使ってGitHub上のリポジトリに対してgrepをするツールです。

特徴は、全てGitHub APIを通じて実行するためローカルに git clone することなくgrepできることです。

また、APIを使っている特徴を活用して複数リポジトリに対してgrepすることなども可能になっています。

あと実行が遅いです。ひたすらGitHub APIを叩いているので...*1

インストール

gh-grep というコマンドとしてHomebrewなどでのインストールも可能ですが、

$ brew install k1LoW/tap/gh-grep

GitHub CLI extensionとしてもインストールできるようにしています。この場合は gh grep というサブコマンドになります。

$ gh extension install k1LoW/gh-grep

使い方

git grep に近いですが、 --owner だけは必須オプションになっています。

例えば、自分のリポジトリのプロジェクトルートにおいてあるDockerfileのベースイメージを検索したい時、

$ gh grep ^FROM --include=Dockerfile --owner k1LoW
k1LoW/centve:Dockerfile:FROM centos:7
k1LoW/docker-alpine-pandoc-ja:Dockerfile:FROM frolvlad/alpine-glibc
k1LoW/docker-sshd:Dockerfile:FROM docker.io/alpine:3.9
k1LoW/gh-grep:Dockerfile:FROM debian:buster-slim
k1LoW/ghdag:Dockerfile:FROM debian:buster-slim
k1LoW/ghdag-action:Dockerfile:FROM ghcr.io/k1low/ghdag:v0.16.0
k1LoW/ghput:Dockerfile:FROM alpine:3.13
k1LoW/ghput-release-action:Dockerfile:FROM ghcr.io/k1low/ghput:v0.12.0
k1LoW/github-script-ruby:Dockerfile:FROM ghcr.io/k1low/github-script-ruby-base:v1.1.0
[...]

という感じで書けます。

「使っているActionをリストアップする」などは、以下のように書けます。

$ gh grep uses: --include=.github/workflows/* --owner k1LoW | sed -e 's/.*uses:\s*//g' | sort | uniq -c
   9 ./
   1 EndBug/add-and-commit@v7
   2 actions/checkout@master
  10 actions/checkout@v1
  50 actions/checkout@v2
  18 actions/setup-go@v1
  21 actions/setup-go@v2
   4 aquasecurity/trivy-action@master
[...]

--owner オプションは Organization(org) に対してもそのまま使えますので、「orgで使っているActionをリストアップする」ということも実施可能です。

また、gh-grep ならではのオプションとして --url というのがあり、grepでマッチした行のURLを出力してくれます。

「まだioutilを使っているところを探してWebUIで確認したい」というときなどは

$ gh grep 'ioutil\.' --include=**/*.go --owner k1LoW --repo ghput --url
https://github.com/k1LoW/ghput/blob/main/gh/gh.go#L300
https://github.com/k1LoW/ghput/blob/main/gh/gh.go#L313
$ gh grep 'ioutil\.' --include=**/*.go --owner k1LoW --repo ghput --url | xargs open

という感じで、macOSならパイプで open コマンドに値を渡すことでそれぞれブラウザで開いてくれます。

今後

git grep を参考に、欲しいと思ったオプションを追加していこうと思います。UTF-8以外の文字コードにも対応したいです。

あと、APIに優しくできる機能は追加したいです。

カジュアルにリポジトリgrepしたくなったとき、是非使ってみてください。

*1:並行処理できるようにすればもう少し早くなるとは思いますが、APIエンドポイントに対して優しくないので高速化だけを目的としたチューニングは今のところしない予定です。

Colima(Lima + Docker)で開発をしてみている

ふとDocker Desktop for Mac以外の環境でDockerイメージを使った開発できるか試したくなったのでやってみています。

私が今選択しているのは Colima というツールです(このエントリ時点でv0.2.2です)。

github.com

Colimaは何をしているかというとLimaとDocker(や、必要に応じてcontainerdやKubernatesも)をいい感じに設定してくれるツールです*1

Lima

LimaはmacOS上でLinuxVMを起動して使えるようにしてくれるツールです。

github.com

READMEでは

Lima can be considered as a some sort of unofficial "macOS subsystem for Linux", or "containerd for Mac".

と書いてありますので、上記のようなイメージを持ってもらうと良さそうです。

Colima

Limaだけでも便利なのですが、Colimaはそこからさらに1コマンドで

  • Limaの設定ファイルを生成してLinux環境を作ってくれる
  • Linux on Limaの環境にDockerをインストールしてセットアップしてくれる
  • ホスト側(つまりmacOS側)のDocker CLIとDocker on Linux on Limaを透過的に接続できるような設定をホスト側にしてくれる

ということをしてくれます。

Colimaのはじめ方

1. Docker Desktop for Mac のアンインストール

まず、Docker Desktop for Macをアンインストールする必要があります。これが一番大きな障壁かもです。

FAQにも

Can it run alongside Docker for Mac?

No, except when started with Containerd runtime. Colima assumes to be the default Docker context and will conflict with Docker for Mac. You should run either, not both.

とあり、Colimaの「Docker CLIとDocker on Linux on Limaを透過的に接続できるような設定」が競合すると思われます。

ちなみに、全く保証はできませんが 私の環境では、Docker Desktop for Macをアンインストールしても、再インストールしたら使っていたDockerコンテナやDockerイメージは残っていました。「試しにColimaを使ってみる」もできそうです。 全く保証はできませんが

とりあえず、Docker Desktop for Macをアンインストールする必要があります。

私の場合はDocker Desktop for MacをHomebrew Caskでインストールしていたので、以下のコマンドを実行してアンインストールしました。

$ brew uninstall --casks docker

2. Colima(とLima)とDockerのインストール

Homebrewで以下をインストールします。

  • Colima(と依存しているLima)
  • ホスト側で docker コマンドと docker compose コマンドを使うためのDockerとdocker-compose
$ brew install colima docker docker-compose
$ ln -sfn $(brew --prefix docker-compose)/bin/docker-compose ~/.docker/cli-plugins/docker-compose # docker-composeを `docker compose` とサブコマンドとして実行できるようにするためにsymlink

3. colima start でDocker on Linux on Limaを起動

あとはColimaを通じてLima上のLinuxを起動して、その上でDockerデーモンを起動するだけです。

Colimaは起動時にVMで使用するコンピューティングリソースやマウントするホスト側のディレクトリを指定できます。指定した設定はLimaの設定ファイルに反映されます*2

私は以下のように実行しています。

$ colima start --cpu 8 --memory 8 --disk 128 --mount '~/src:w' --mount '~/tmp:w'

特に、私はソースコードghq~/src 以下に置いているので --mount ~/src:w というように :w を渡して書き込み可能にしています*3

あとは普通にホスト側から docker ps なり docker pull なりすれば良いです。

たったこれだけです。今までと同じようにDockerなのも良いと感じています。

Colima環境の現時点の課題

今2週間ほどColima環境を使っているのですが、ただ1つを除いて特に問題なくDockerを使った開発ができています*4

その1つの課題というのがホストからマウントしているファイルシステムに関するものです。

ホストとLima VM間はSSHで接続されています。 マウントはsshfsを使用していますし、Colima環境ではホストとDocker on Linux on Limaのdocker.sock の共有もSSHを経由して実行されています。 そのためホスト側のファイルシステムをLima VMを経由してマウントしているファイルを、Dockerコンテナ内からパーミッション変更などをすると失敗します。

以下のIssueが上記の問題に関係するものだと思います。

github.com

環境によっては厳しい条件かもしれません。私の場合は今のところはワークアラウンドは考えたり手元でそのワークアラウンドを適用したりできているので大丈夫です。ただ少し面倒ですね。

というわけで

Colimaの利用レポートでした。

*1:v0.1.xまではBashで書かれていたのですが、最近v0.2.xからGoでリライトされました。それくらいシンプルです

*2:デフォルトだと ~/.lima/colima/lima.yml になると思います

*3:前までは--mountオプションはなく、Limaの設定ファイルを書き換える必要があったのですが、https://github.com/abiosoft/colima/pull/17 を通じて機能要望をし、v0.2で導入してもらいました

*4:この間に更新しているOSSなDockerイメージなどは全部Colima環境です。 https://github.com/k1LoW?tab=packages

YAMLファイルの全ての値にos.Expandenv(os.Expand)を適用するgithub.com/k1LoW/expandを作った

私は作るツールの設定ファイルのフォーマットをYAMLにすることが多いです。

そして各値で環境変数を展開できるようにする機能を追加することがあります。

以下のような設定ファイルを読み込んだ際に、 ${COVERAGE_ACCEPTABLE}${COVERAGE_BADGE_PATH}環境変数から読み込んで変数展開してあげる機能を追加します。

coverage:
  acceptable: ${COVERAGE_ACCEPTABLE}
  badge:
    path: ${COVERAGE_BADGE_PATH}
comment:
  enable: ${COMMENT_ENABLE}

値が文字列であればYAMLファイルをエンコードしてから os.Expandenv を呼べばいいですし、今まではそのように作っていたのですが以下のような課題がありました。

数値や真偽値の変数展開の実装が面倒

${xxxxx} はどう見ても文字列なので、数値や真偽値の変数展開を実現するためにはUnmarshalする際に一工夫が必要になります。

そこまでの実装のモチベーションが持てず、大抵「環境変数展開対象外」にしていました。

os.Expandenv をひたすら書く必要がある

YAMLの文字列を構造体に変更した後に変数展開をする場合、os.Expandenvを各値に対してひたすら適用する必要があります*1。適用漏れもあったり。

そういったYAMLを設定ファイルに採用しているツールが手元に増えてきたので、YAMLファイルの全ての値に対してos.Expandenv(os.Expand)を適用する関数を作ってみました。

github.com

github.com/k1LoW/expand

使い方は簡単で、以下のように expand.Expand* を呼べば []byte や string なYAMLの各値にだけos.Expandenv(os.Expand)を適用します。

c := &Config{}
p := "config.yml"
buf, err := os.ReadFile(p)
if err != nil {
    return err
}
if err := yaml.Unmarshal(expand.ExpandenvYAMLBytes(buf), c); err != nil {
    return err
}

提供している関数は expand package - github.com/k1LoW/expand - pkg.go.dev をみてください。

実装は、ありがたいことに github.com/goccy/go-yaml が Lexer をpublicなAPIとして公開してくれているので、それを利用させてもらっています。 Lexer の活用事例として ycat があり、かなり参考にさせてもらいました*2

github.com

今後

せっかくYAMLのための関数を作ったので、他のフォーマットや構造体の関数も作ってみようかなとは思いました。思っただけです。

もし何かあればフィードバックもらえると嬉しいです。

*1:全ての値を走査するために「reflectを使って再帰的に」という手段もあるとは思います

*2:特に func (p *Printer) PrintTokens(tokens token.Tokens) string。ほぼそのままといってもいいです