Clojure Inspired のシステムスクリプト言語 janet と janet-sh による Shell Scripting

この記事は [Clojure Adevent Calendar 2023](https://qiita.com/advent-calendar/2023/clojure) の21日目の記事です。 Clojure ではなく Janet の記事なので少し場違いかもですが Clojurian 視点で書いております。C言語で書かれたシステムへプラグイン的な機構を提供する仕組みを設計するために調べる機会があったので書いてみることにしました。

Janet は Clojure Inspired なシステムスクリプト言語です。特に新しい言語ではないのですが日本語の情報は少ないようです。Janet のメイン用途の システムスクリプト というのは分かりにくいかもしれませんが Lua [^2] や GNU Guile [^3] に似た用途を想定していると理解するとイメージしやすいです。つまり、 C や C++ などのシステムプログラミング言語で構築されたシステムに小さな拡張パーツとして埋め込んで使う用途を想定しています。

Lua や GNU Guile との違いでいうと単一バイナリで完結する、埋め込みの簡単さ、そして標準ライブラリの機能の充実などがあります。 C や C++ にプラグイン機構を提供するのに必要なものが完結にして必要十分に最初から揃っています。

コンパイラは C99 で書かれており Windows, MacOS, Linux 上で動きます。 開発環境のインストールは Mac なら Homebrew で一発です。

構文は Clojure に近いものの、 微妙に Clojure と異なる部分がけっこうあり、逆にその中途半端な違いが厄介だという意見もあります [^1] 。一例を挙げるとコメントの構文が `;` ではなく `#` です。用途的に Shell の構文に寄せたようにも見えますが最初は戸惑います（しかし慣れると特に気になりません）。`#`がコメントで使われるとなると匿名関数の書き方（Clojureの`#()`）にも影響するという感じで、その他のリーダーマクロ（Janetでは`shorthand notation`）も細かく違いがあります。

というわけで先に注意点ですが、 **Clojure ユーザが Shell Scripting をClojure でやりたいという場合や Clojure 入門者が Clojure を学びたいという目的の場合は [Babashka](https://babashka.org) のほうが通常は適しているので Babashka を選択してください。**
Janet は Clojure との互換性をそこまで大きく重視はしていないですが、固有の機能や特長がありそこがマッチするor気に入るなら楽しい選択肢だと思います。

## Install

Mac なら `brew install janet` でOKです。 Windows と Linux は[こちら](https://janet-lang.org/docs/index.html)を参照してください。

janet cli

```
$ janet -h
usage: janet [options] script args...
Options are:
  -h : Show this help
  -v : Print the version string
  -s : Use raw stdin instead of getline like functionality
  -e code : Execute a string of janet
  -E code arguments... : Evaluate an expression as a short-fn with arguments
  -d : Set the debug flag in the REPL
  -r : Enter the REPL after running all scripts
  -R : Disables loading profile.janet when JANET_PROFILE is present
  -p : Keep on executing if there is a top-level error (persistent)
  -q : Hide logo (quiet)
  -k : Compile scripts but do not execute (flycheck)
  -m syspath : Set system path for loading global modules
  -c source output : Compile janet source code into an image
  -i : Load the script argument as an image file instead of source code
  -n : Disable ANSI color output in the REPL
  -N : Enable ANSI color output in the REPL
  -l lib : Use a module before processing more arguments
  -w level : Set the lint warning level - default is "normal"
  -x level : Set the lint error level - default is "none"
  -- : Stop handling options
  ```

## REPL

オプションなしで実行すると repl が起動します。

```janet
$ janet
Janet 1.32.1-meson macos/aarch64/clang - '(doc)' for help
repl:1:> (inc 0)
1
repl:2:>
```

## 組み込みの関数とマクロ

Janet の特長の１つは 300 を超える関数とマクロがコアライブラリに付属していることです。 Clojure の得意なシーケンスの操作も書き味は似ています。

```janet
repl:3:> (def a (range 0 10))
@[0 1 2 3 4 5 6 7 8 9]

repl:4:> (->> a (map inc) (filter (fn [n] (= 0 (mod n 2)))) reverse (take 3))
(10 8 6)
```

それらは `(doc)` 関数でドキュメントを参照できます。

```janet
repl:5:> (doc def)


    special form

    (def ...)

    See https://janet-lang.org/docs/specials.html


nil
repl:6:> (doc inc)


    function
    boot.janet on line 136, column 1

    (inc x)

    Returns x + 1.


nil
```

## Syntax

### Mutable なデータ構造と `@`

上で `range` 関数のアウトプットが `@[0 1 2 3 4 5 6 7 8 9]` のように `@` が付いていたのが気になったかと思います。これはオブジェクトが Mutable であることを表しています。 Janet では Clojure とは異なり Immutable がデフォルトではなく、オブジェクトは Mutable なものと Immutable なものをたいていどちらも用意しており `@` 接頭辞で指定します。

`[]` は Clojure ではベクタのリテラル表記ですが Janet では Immutable な配列のリテラル表記であり Tuple という型です。同様に `@[]` は Mutable な配列のリテラルで Array 型です。

```janet
repl:1:> [1 2 3 4]
(1 2 3 4)

repl:2:> @[1 2 3 4]
@[1 2 3 4]

repl:3:> (type [1 2 3 4])
:tuple

repl:4:> (type @[1 2 3 4])
:array
```

このように Janet では Mutable なデータ構造を `@` プレフィックスを付けることで表記します。他にも、Associative 構造として Mutalble な `table` と Immutable な `struct` 、文字列として Mutable な `buffer` と Immutable な `string` などがあります。

```janet
repl:3:> (def t @{:age 22 :name "Alice"})
@{:age 22 :name "Alice"}

repl:4:> (type t)
:table

repl:5:> (def s {:age 22 :name "Alice"})
{:age 22 :name "Alice"}

repl:6:> (type s)
:struct

repl:7:> (type "Immutable")
:string

repl:8:> (type @"Mutable")
:buffer
```

`table` は `set` 特殊形式で変更が可能です。

```janet
repl:9:> (set (t :fav) "Janet")
"Janet"

repl:10:> (set (t :age) (inc (t :age)))
23

repl:11:> t
@{:age 23 :fav "Janet" :name "Alice"}
```

しかし `struct` は変更不可です。

```janet
repl:12:> (set (s :fav) "Janet")
error: expected array, table or buffer, got <struct 0x0001511043C8>
  in _thunk [repl] (tailcall) on line 48, column 1
```


他にも Janet 特有の構文の構文で Clojure ユーザがひっかかるポイントをいくつか列挙します。

### コメント `#`

```janet
repl:12:> (string/find "el" "Hello") # コメント
1
```

### splicing `;`

`;` がコメントではなく `splice` のリーダーマクロとして使われています。ただし、Janet では リーダーマクロ ではなく **`shorthand notation`** と呼ばれています。

### unquote `,`

### quasiquote `~`

```janet
repl:14:> (def sum-ast ~(+ ,nums))
(+ (1 2 3))

repl:1５:> ~(+ ,;nums)
(+ 1 2 3)
```

### Destructuring

Destructuring は Clojure 同様に使えますが、異なるところとして def でも使えます。これは特に違和感のない拡張ですが、そんなに使いどころがあるかはちょっと分からないです。

```janet
repl:54:> (def [a b] [10 20])
(10 20)

repl:55:> [a b]
(10 20)
```

### 匿名関数

Clojure だと `#()` で引数を `% %2 %3 ..` で受け取りますが、Janet ではこれが `|()` と `$ $1 $2 ..` です。 `#` をコメントに使ってしまったので他の記号を持ってくるのはまだ分かるのですが、引数の添字が 0 始まりで異なるのはよく間違えます。罠です。

```janet
repl:8:> (|(+ 1 $ $1) 10 20)
31

repl:9:> (|(+ 1 $0 $1) 10 20)
31
```


## Build Executable

Repl からではなくビルドして実行してみましょう。

まず `project.janet` という設定ファイルを作成します。 

```janet
(declare-project :name "main")

(declare-executable
 :name "hello"
 :entry "main.janet")
```

１行目のプロジェクト名はなんでもいいです。 `declare-executable` は実行可能ファイル名（`:name`）、そのソースとなる janet プログラムのエントリポイント（`:entry`） を定義しています。つづいて上記で定義した `main.janet` です。

```janet
(defn main [&]
    (print "hello, world"))
```

`main` 関数だけを定義しています。最小限だとこれだけでOKです。ビルドには `jpm` という Janet 公式のパッケージ・マネージャのコマンドを使用します。

```sh
$ jpm build
generating executable c source build/hello.c from main.janet...
compiling build/hello.c to build/build___hello.o...
linking build/hello...
```

`jpm build` コマンドは `project.janet` ファイルを読み込んでその内容にしたがってビルドを実行します。生成されたのは build フォルダの `hello` ファイルです。

```sh
$ build/hello
hello, world

$ file build/hello
build/hello: Mach-O 64-bit executable arm64

$ du -h build/hello
660K	build/hello

$ otool -L build/hello
build/hello:
	/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1336.0.0)
```

これはちゃんとした配布可能なネイティブイメージになっています。どのようにビルドされているのでしょうか。 Janet はソースコードをまずバイトコードにコンパイルします。そのあとそれを C 言語のファイルに書き込みます。この C のファイルでは Janet のランタイムの起動などもろもろ必要な処理も書かれます。その後にその C のファイルをシステムの C　コンパイラでコンパイルしています。

Hello, World のプログラムが 660 KB と大きめになっている理由は、 Janet の実行可能ファイルが Janet ランタイムをまるごと持ち込んでいることに起因します。しかし逆に言うとランタイム（これには GC も入っています！）込みでこのサイズというのは小さいとも言えます。これは Janet のランタイムがとても軽量だからです。Janet の言語のコアの部分は `do`, `def`, `var`, `set`, `if`, `while`, `break`, `fn` の８つの基本的な命令（とマクロをサポートするための quote や unquote　などの ５つの命令）のみですごくコンパクトに実装されているそうです。

## jpm

`jpm` は Janet 公式のビルドツールで、 Homebrew で Janet をインストールしていれば一緒にインストールされていてパスも通っているはずです。 Janet プロジェクトのライブラリの依存関係を定義しビルドを支援します。 Leiningen の `project.clj` や Clojure の `deps.edn` 、 Node/NPM の `package.json` に近い位置付けのものです。

npm script のようにタスクランナーとしても使えます。一般的なタスクランナーにおけるタスクのことを Janet では `rule` と呼びます。

```janet
(task "main-rule" ["pre-rule"]
  (print "2. hello"))

(task "pre-rule" []
  (print "1. clean build dir"))
```

```sh
$ jpm rules
main-rule
pre-rule

$ jpm run main-rule
1. clean build dir
2. hello
```

## Scripting

ここから C 言語への埋め込みについて書くのもいいのですが、今回は Shell Script 代替として Janet を使うとき使える janet-sh を紹介します。 Shell Script の構文を驚くほどそのまま組み込んでシームレスに統合できます。

https://github.com/andrewchambers/janet-sh

次のコマンドで sh ライブラリがインストールされます。 jpm は npm とは逆でデフォルトがグローバルインストールなので次のコマンドではグローバルな環境にインストールされることに注意してください[^4]。

```ｓｈ
jpm install sh
```

`janet-sh` の `$` という関数を主に使います。文字列として Shell Script を埋め込んで eval するような形式ではなく、できるだけS式に調和した Shell Script がインラインで書けます。

```janet
#!/usr/bin/env janet
(use sh)

($ echo "hey janet" | tr "a-z" "A-Z")
```

```sh
$ ./greet
HELLO, JANET-SH
```

リダイレクトもいけます

```janet
#!/usr/bin/env janet
(use sh)

($ echo "Hello, janet-sh" >(file/open "output.txt" :w))
($ cat ./output.txt)
```

```sh
$ ./greet
Hello, janet-sh

$ cat ./output.txt
Hello, janet-sh
```

次は aws-cli で S3 バケットを列挙して開発環境用のバケットの数をカウントしています。
`$<` は Shell のリダイレクトと同様で、文字列として受け取っています。

```janet
#!/usr/bin/env janet
(use sh)

(->> ($< aws s3 ls | sort -n | cut -d " " -f 3)
  (string/split "\n")
  (filter |(string/has-prefix? "dev-" $))
  length
  print)
```

```sh
$ ./s3-dev-bucket
12
```

Shell Script がシームレスに統合されていますね（それがそんなに嬉しい例にはなっていないですが）。

最後に Shell にはない janet-sh の面白い機能を紹介します。 Janet のパターンマッチを使ってパイプラインのステージごとの終了ステータスをハンドリングできます。

```janet
#!/usr/bin/env janet
(use sh)

(def url "https://google.com")

(match (run curl ,url | gzip | xxd)
  [0 0 0] (print "all ok")
  [6 _ _] (print "Error: curl failed, no nw connectivity")
  [_ _ _] (print "Error: curl failed, something wrong")
  [0 _ _] (print "Error: gzip failed")
  [0 0 _] (print "Error: xxd failed"))
```

```sh
$ ./curl-janet
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   220  100   220    0     0    722      0 --:--:-- --:--:-- --:--:--   735
00000000: 1f8b 0800 4095 8265 0003 4d4f 4d0b 8230  ....@..e..MOM..0
00000010: 18be 0ffa 0f63 775d d225 eadd c0d0 3028  .....cw].%....0(
00000020: 82f0 d251 e69b 0b9c b37c cdfa f769 75e8  ...Q.....|...iu.
00000030: f87c f07c 4096 1ff6 1ab2 344e 3438 a482  .|.|@.....4N48..
00000040: 5ba2 36c0 5b7f 7d28 617c 43d8 5040 af16  [.6.[.}(a|C.P@..
00000050: 05ff 2125 089f 242d b97a 6d6c 71ef 9054  ..!%..$-.zmlq..T
00000060: 4f97 6029 3483 7c97 ef53 bd98 47fc e01f  O.`)4.|..S..G...
00000070: 5882 fc12 20bf 0d9b 6372 1e5d 59f4 6f19  X... ...cr.]Y.o.
00000080: 11cb 2df2 d29b de8d 05dc 161d 7793 c620  ..-.........w..
00000090: e6d9 29dd 2a31 adea 5652 0ec3 1056 de57  ..).*1..VR...V.W
000000a0: 3586 c63b 29b4 c53b 828c 7538 6320 3ff1  5..;)..;..u8c ?.
000000b0: 63e0 f469 c6de 761d 283b dc00 0000       c..i..v.(;....
all ok


# ワイファイをOFFにして実行
$ ./curl-janet
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0curl: (6) Could not resolve host: google.com
00000000: 1f8b 0800 2996 8265 0003 0300 0000 0000  ....)..e........
00000010: 0000 0000                                ....
Error: curl failed, no nw connectivity
```


## Wrap up

Clojure ユーザにとって最近のスクリプティングの王道は Babashka でしょう。 Janet のような Syntax の微妙な違いもなくすんなり使えるためスクリプティングなら自分も bb を使うことが多いです。しかし Janet は c/c++ との統合（今回は紹介してないですが）や janet-sh による shell との親和性の高さなど面白い特徴がいくつかあります。良かったら試してみてください[^5]。


## 参考

- [Why Janet?](https://ianthehenry.com/posts/why-janet/)
- [Janet for Mortals](https://janet.guide/)
- [Learn Janet in Y Minutes](https://learnxinyminutes.com/docs/janet/)
- [Janet Programming Language](https://janet-lang.org/index.html)


[^1]: https://www.reddit.com/r/lisp/comments/okfzfj/comment/h5nb0zq/ 
[^2]: https://lua.org/
[^3]: https://www.gnu.org/software/guile/
[^4]: ローカルインストールする場合には `--local` フラグを付けます。また削除したい場合は `jpm uninstall sh` です。
[^5]: c言語との連携もどこかで書きたい。今回はTimelimitでした。