Common Lisp (Parenscript) で GAS を書く
GAS (Google Apps Script) を Common Lisp (Parenscript) で書けるようにしたという話です。テンプレートを作ったのでその使い方やら中身の話やらを簡単に書いていきます。
なお、個人で実際に利用しているものとしては日報メールを生成する下記のリポジトリがあります。1年ぐらい非 Git 管理の純 JavaScript な GAS として利用していたものを、clasp で Git 管理下に置くついでに Common Lisp 化したものです。
使い方
ros template
用のテンプレートを作成したのでその使い方についてです。
なお、ros template
自体について詳細を知りたい場合は過去記事参照です。
インストール
まずはテンプレートを clone & import します。
$ git clone https://github.com/eshamster/template-cl-gas.git
$ cd template-cl-gas/src
$ ros template import
うまく行っていれば ros template checkout
コマンドで次のように candiates:
に cl-gas
テンプレートが見えるはずです。
$ ros template checkout current default is "default" candidates: default. cl-gas
また、依存パッケージの ps-experiment が quicklisp リポジトリに登録されていない *1 のでインストールしておきます(コンソールコマンドがある訳ではないので ql:quickload
できるようになれば別の方法でも良いです)。
$ ros install eshamster/ps-experiment
npm 側の準備としては @google/clasp をインストールしてログインしておくだけです。ちなみに、リモートサーバ上で開発しているような場合は clasp login --no-localhost
でログインすると良いようです(参考)。
$ npm i @google/clasp -g
$ clasp login
プロジェクト作成
プロジェクト作成は次のようにします
ql:quickload
が認識できる場所に空のフォルダを作成します$ cd ~/.roswell/local-projects $ mkdir sample-cl-gas
clasp create
でプロジェクトを初期化します$ cd ~/.roswell/local-projects/sample-cl-gas $ clasp create sample-cl-gas --type standalone $ find . . ./.clasp.json ./appsscript.json
ros init cl-gas
でcl-gas
テンプレートからプロジェクトを作成します$ cd ~/.roswell/local-projects/sample-cl-gas $ ros init cl-gas sample-cl-gas \ --license LLGPL \ --description "This is a sample using template-cl-gas." $ find . . ./.clasp.json ./appsscript.json ./.gitignore ./.claspignore ./.clasp.json.in ./src ./src/main.lisp ./src/compile.lisp ./main.lisp ./compile.ros ./sample-cl-gas.asd ./README.markdown ./Makefile
トラブルシューティング
ros init
時に cl-gas
テンプレートがうまく適用されずに cl-gas.ros
が生成されて終わってしまう場合があります。
$ ros init cl-gas sample-cl-gas \ --license LLGPL \ --description "This is a sample using template-cl-gas." Successfully generated: cl-gas.ros $ ls . ./.clasp.json ./appsscript.json ./cl-gas.ros # ← これができただけ
原因解明に至っていないですが、回避方法は以下を実行することです。これで、次に ros init
を実行した際にリコンパイルが走ってひとまずテンプレートが正常に適用されるようになります。
touch /usr/local/etc/roswell/init.ros
気が向いたらきちんと調査して解決なり issue 化なりしたいですが、何しろ上記の通りリコンパイルが走った段階で直ってしまうので printf デバッグすら簡単でなく中々辛い...。
アップロード・編集
ここまでの準備をした段階で取りあえず動くものができています。 make push-new
で main.js
の生成と GAS へのアップロードを行います。
$ make push-new
GAS の画面から実行してみると、ログに "Hello GAS on Lisp"
が表示されるはずです。
以降、ファイルを変更した場合は同様に make push-new
することで反映できます。
さて、ファイルを編集する場合ですが、先程の "Hello GAS on Lisp"
を出力するコードに対応するのが src/main.lisp
です。
(defpackage sample-cl-gas/src/main (:use :cl :ps-experiment :parenscript) (:export :main)) (in-package :sample-cl-gas/src/main) (defun.ps main () (-logger.log "Hello GAS on Lisp"))
通常の Common Lisp 開発に近いサイクルで開発できることのデモとして、 src/hoge.lisp
ファイル追加してみます。
(defpackage sample-cl-gas/src/hoge (:use :cl :ps-experiment :parenscript) (:export :hoge)) (in-package :sample-cl-gas/src/hoge) (defun.ps hoge () (-logger.log "Hello hoge on Lisp"))
そして、src/main.lisp
を以下のように編集します。 :export
や :import-from
が通常の作法で行えることが分かるかと思います。出力される JavasScript 上でも Common Lisp のパッケージ・シンボルシステムに従った名前空間の分割をしているので、別パッケージとの名前衝突も気にする必要はありません。
(defpackage sample-cl-gas/src/main (:use :cl :ps-experiment :parenscript) (:export :main) (:import-from :sample-cl-gas/src/hoge :hoge)) (in-package :sample-cl-gas/src/main) (defun.ps+ main () (hoge))
これを make push-new
して改めて実行することで、ログに "Hello hoge on Lisp"
が表示されます。
付録:Parenscript + ps-experiment 上での開発概略
Parenscript + ps-experiment の開発サイクルを記事にしたことがない気がするので簡単に書くと、下記ぐらいを注意しておけば結構 Common Lisp ライクに書けるのではないでしょうか*2。先程見たようにパッケージ分割も Common Lisp の作法に則っています。
cl
に加えてparenscript
,ps-experiment
を:use
するdefXXX
の代わりにdefXXX.ps
またはdefXXX.ps+
を利用する- 用意があるのは
defvar
,defparameter
,defun
,defmacro
,defgeneric
,defmethod
,defstruct
です .ps
と.ps+
の違いは、前者が JavaScript 用の定義のみを行うのに対し、後者は加えて同等の Common Lisp コードも定義する点です- Common Lisp コンパイラのエラー・警告という恩恵を得られるので可能な限り
.ps+
を利用するのが良いです defun.ps
,defgeneric.ps
,defmethod.ps
については空の Common Lisp コードも生成するので、 それらで定義したものを.ps+
系から利用してもコンパイルエラーにはなりません(実行時エラーになります)
- Common Lisp コンパイラのエラー・警告という恩恵を得られるので可能な限り
- 用意があるのは
他、どうしても JavaScript との差異を意識しないといけない場面はあります。目立つものは下記でしょうか。
- 「ハイフン+文字」が大文字と解釈される。例:
ps-experiment
→psExperiment
(Parenscript の仕様)- JavaScript 側で定義されたものを呼び出す際に意識が必要な点になります
- 大文字が連続するようなケースで煩わしい場合は
(enable-ps-experiment-syntax)
を利用すると#j.psExperiment#
のように書けます
- 空リストと
nil
(JavaScript上のnull
) は同一ではない- Common Lisp 上のリストの初期化は良く
nil
で行いますが(list)
のようにする必要があります
- Common Lisp 上のリストの初期化は良く
- 0 が false 扱い
defvar.ps
などで定義した変数はコピーが export される- そのため値の変更が反映されないという罠があります(気づかないとデバッグに結構手間がかかる...)
- 特にマクロ内で展開される変数でやりがちです
- パッケージ外に露出するものについては getter, setter を用意するのが無難です
- そのため値の変更が反映されないという罠があります(気づかないとデバッグに結構手間がかかる...)
- Parenscript 上で用意されていない Common Lisp 関数は結構ある
- ps-experiment/ps-macros-for-compatibility でマクロとして補っているものもありますが、気まぐれで追加しているので網羅性は全くないです...
実現方法
実現方法について1ファイルの Roswell スクリプトで行う方法を見てみます。テンプレートとして提供しているものは、適宜分割をしたり asd ファイルを追加してプロジェクト化したりと整えただけで、ベースの実現方法は1ファイルの場合と変わりません。
まずは少しダメな例です。
#!/bin/sh #|-*- mode:lisp -*-|# #| exec ros -Q -- $0 "$@" |# (progn ;;init forms (ros:ensure-asdf) #+quicklisp(ql:quickload '(:parenscript :ps-experiment :split-sequence) :silent t) ) (defpackage :ros.script.test.3797433527 (:use :cl :parenscript :ps-experiment)) (in-package :ros.script.test.3797433527) (defun.ps test () (-logger.log "Hello GAS on Lisp")) (defun main (&rest argv) (declare (ignorable argv)) (with-open-file (out "main.js" :direction :output :if-exists :supersede :if-does-not-exist :create) (princ (with-use-ps-pack (:this) (test)) out)))
with-use-ps-pack
はパッケージ間の依存性を見つつ、export, import も適宜行いつつ JavaScript コードを文字列として出力する ps-experiment のマクロです*3。ちなみに、この辺りの細かい(細か過ぎる)話は過去に記事にしています。
さて、その with-use-ps-pack
の出力を素直に princ
している訳ですが何がダメかというと...
var psExperiment_defines_defmethod = (function() { /* ※ defgeneric, defmethod 実現のためのコード群なので略 */ })(); var ros_script_test_3797433527 = (function() { /* --- import symbols --- */ /* --- define objects --- */ function test() { __PS_MV_REG = []; return Logger.log('Hello GAS on Lisp'); }; function __psMainFunc__() { __PS_MV_REG = []; return test(); }; /* --- extern symbols --- */ return { '_internal': { 'test': test, '__psMainFunc__': __psMainFunc__, } }; })(); ros_script_test_3797433527._internal.__psMainFunc__();
with-use-ps-pack
内に書いた処理は __psMainFunc__
関数として出力していますが、末尾でこれをそのまま実行してしまっています。GAS としてはこの __psMainFunc__
関数を実行する関数が欲しいところです。
ということで、Roswell スクリプトの main
関数を下記のように変更します。
(defun main (&rest argv) (declare (ignorable argv)) (let* ((str (with-use-ps-pack (:this) (test))) (splitted (split-sequence:split-sequence #\Newline str))) (with-open-file (out "main.js" :direction :output :if-exists :supersede :if-does-not-exist :create) (format out "~{~A~%~} function main() { ~A }" (butlast splitted) (car (last splitted))))))
...末尾の一行を切り取って function main() { }
で囲うという見るからに汚いことをしています。本来は with-use-ps-pack
に適切なオプションをつけてどうにかできるようにすべきですが、そこまでのモチベーションが湧いていないのでひとまずこんなもので...。そんな訳で次のように無事に GAS に上げて動かせるコードが出力されました。
var psExperiment_defines_defmethod = (function() { /* ※ defgeneric, defmethod 実現のためのコード群なので略 */ })(); var ros_script_test_3797433527 = (function() { /* --- import symbols --- */ /* --- define objects --- */ function test() { __PS_MV_REG = []; return Logger.log('Hello GAS on Lisp'); }; function __psMainFunc__() { __PS_MV_REG = []; return test(); }; /* --- extern symbols --- */ return { '_internal': { 'test': test, '__psMainFunc__': __psMainFunc__, } }; })(); /* ※ここまでは上記の出力と同じ */ function main() { ros_script_test_3797433527._internal.__psMainFunc__(); }
完全に余談ですが、省略している defgeneric
, defmethod
の(サブセットの)実装の話は下記で記事にしています。
*1:登録したいなという気持ちはありつつ、冴えた名前が思いつかないのでそのままになっています...
*2:Common Lisp ライクに書くための皮を Parenscript に被せるのが ps-experiment の主要な役割でもあります
*3:第1引数で依存解決の起点となるパッケージ群をキーワードで指定します。ただ、依存解決を入れた時点で自身のパッケージを示すエイリアスである :this 以外を指定することがなくなってしまったので、何かオプションを追加したくなったらこちらは obsolete して別のマクロを用意しようかなという気持ちではいます