Common Lisp (Parenscript) で GAS を書く

GAS (Google Apps Script) を Common Lisp (Parenscript) で書けるようにしたという話です。テンプレートを作ったのでその使い方やら中身の話やらを簡単に書いていきます。

なお、個人で実際に利用しているものとしては日報メールを生成する下記のリポジトリがあります。1年ぐらい非 Git 管理の純 JavaScript な GAS として利用していたものを、clasp で Git 管理下に置くついでに Common Lisp 化したものです。

github.com



使い方

ros template 用のテンプレートを作成したのでその使い方についてです。

github.com

なお、ros template 自体について詳細を知りたい場合は過去記事参照です。

eshamster.hatenablog.com

インストール

前提として Roswellnpm が必要になります。

まずはテンプレートを 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

プロジェクト作成

プロジェクト作成は次のようにします

  1. ql:quickload が認識できる場所に空のフォルダを作成します

     $ cd ~/.roswell/local-projects
     $ mkdir sample-cl-gas
    
  2. clasp create でプロジェクトを初期化します

     $ cd ~/.roswell/local-projects/sample-cl-gas
     $ clasp create sample-cl-gas --type standalone
     $ find .
     .
     ./.clasp.json
     ./appsscript.json
    
  3. ros init cl-gascl-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-newmain.js の生成と GAS へのアップロードを行います。

$ make push-new

GAS の画面から実行してみると、ログに "Hello GAS on Lisp" が表示されるはずです。

f:id:eshamster:20200515002454p:plain

以降、ファイルを変更した場合は同様に 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+ 系から利用してもコンパイルエラーにはなりません(実行時エラーになります)

他、どうしても JavaScript との差異を意識しないといけない場面はあります。目立つものは下記でしょうか。

  • 「ハイフン+文字」が大文字と解釈される。例: ps-experimentpsExperiment(Parenscript の仕様)
    • JavaScript 側で定義されたものを呼び出す際に意識が必要な点になります
    • 大文字が連続するようなケースで煩わしい場合は (enable-ps-experiment-syntax) を利用すると #j.psExperiment# のように書けます
  • 空リストと nil (JavaScript上の null) は同一ではない
    • Common Lisp 上のリストの初期化は良く nil で行いますが (list) のようにする必要があります
  • 0 が false 扱い
  • defvar.ps などで定義した変数はコピーが export される
    • そのため値の変更が反映されないという罠があります(気づかないとデバッグに結構手間がかかる...)
      • 特にマクロ内で展開される変数でやりがちです
    • パッケージ外に露出するものについては getter, setter を用意するのが無難です
  • Parenscript 上で用意されていない Common Lisp 関数は結構ある

実現方法

実現方法について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。ちなみに、この辺りの細かい(細か過ぎる)話は過去に記事にしています。

eshamster.hatenablog.com

さて、その 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 の(サブセットの)実装の話は下記で記事にしています。

eshamster.hatenablog.com


*1:登録したいなという気持ちはありつつ、冴えた名前が思いつかないのでそのままになっています...

*2:Common Lisp ライクに書くための皮を Parenscript に被せるのが ps-experiment の主要な役割でもあります

*3:第1引数で依存解決の起点となるパッケージ群をキーワードで指定します。ただ、依存解決を入れた時点で自身のパッケージを示すエイリアスである :this 以外を指定することがなくなってしまったので、何かオプションを追加したくなったらこちらは obsolete して別のマクロを用意しようかなという気持ちではいます