← 一覧へ戻る

リストのないchoで文字列を分割する

リストも変数代入も持たないchoで、分割した文字列の複数の部分を扱うためにs/withを作った話

前回は、awkのように列を取り出してS式で加工できるテキスト処理ツールchoを紹介しました。今回は、choを作る中で悩んだ「文字列の分割」について書きます。

リストを持たない言語でsplitをどう書くか

choでは意図的に、配列やリスト、タプル、変数代入、ループ、ユーザー定義関数を用意していません。短いワンライナーを書くための道具なので、できることを増やすより、式を読んだときに考えることを少なくしたいからです。Lisp風の構文なのにリストを使えないのは少し寂しいですが……(Lispは「LISt Processing」なのに。)

たとえばEmacs Lispなら、split-string の結果をリストとして受け取れます。

(split-string "api:worker:8080" ":")
;; => ("api" "worker" "8080")

(nth 1 (split-string "api:worker:8080" ":"))
;; => "worker"

choにはリスト型がないので、同じ形では返せません。そこで最初は、分割した文字列から指定位置の一つだけを返す s/part を作りました。

;; 当時の構文。現在のchoでは使えません。
(s/part "api:worker:8080" ":" 2)  ; => "worker"

位置は $1 などと同じく1始まりです。

一つだけ取り出すなら十分です。しかし、複数の部分を使うと同じ文字列を何度も分割する式になります。

;; 当時の構文。現在のchoでは使えません。
(s/join "/"
  (s/part "api:worker:8080" ":" 2)
  (s/part "api:worker:8080" ":" 3))
;; => "worker/8080"

分割結果をリストにせず、一度だけ分割して複数の部分を使うにはどうしたらいいか。そこで考えたのが s/with(s/ は文字列処理を表す接頭辞)です。

分割した値を一時的なレコードにする

s/with は、値を分割し、その結果を $1、$2、…で参照しながら BODY の式を評価します。その式の結果が、s/with の返り値になります。

s/with は、if と同じく特殊形式として扱っています。通常の関数のように引数をすべて先に評価すると、BODY の $1 は元の入力行の1列目を指してしまいます。分割する値と区切り文字を先に評価し、BODY だけを分割後のフィールドで評価するために、この形にしました。

$ echo 'api:worker:8080' | cho '(s/with $1 ":" (s/join "/" $2 $3))'
worker/8080

書式は (s/with VALUE DELIMITER BODY) です。VALUE が分割する値、DELIMITER が区切り文字列、BODY が分割後の値を使う式です。DELIMITER を省略した (s/with VALUE BODY) では、通常の入力行と同じように空白で分割します。

BODY から見ると、渡した値全体が $0、分割後の各部分が $1 以降、列数が NF です。区切り文字列を指定した場合は、空の部分も残ります。

たとえば、入力行の3列目を : で分割し、2番目の部分を s/upper で大文字にします。

$ echo 'job42 ready api:worker:8080' | cho '(p (s/with $3 ":" (s/upper $2)))'
WORKER

外側では $3 が api:worker:8080 ですが、s/with の BODY では $2 が worker を指します。BODY を抜けると元のレコードのフィールド参照に戻ります。

$ echo 'job42 ready api:worker:8080' | cho '(p (s/with $3 ":" $2) $1)'
worker job42

Lispのletに少し似ていますが、ここではBODYの間だけ$1などの参照先を差し替えています。変数に名前を付けたり、値を代入し直したりする操作はありません。

一つの値の中に別の区切り方が必要なら、s/with を重ねられます。

choにはURLのクエリパラメータを取り出す url/query-get もありますが、対象は絶対URLです。HTTPリクエスト行に現れる /api/users?... のような相対URLには使えないため、ここでは s/with で分割します。

$ echo 'GET /api/users?limit=20&sort=asc' | cho '(s/with $2 "?" (s/with $2 "&" (s/with $1 "=" $2)))'
20

まずURLを ? で分け、クエリを & で分け、先頭の limit=20 を = で分けます。段階ごとに $1 や $2 が指す値も変わります。入れ子が深くなるほど読みづらくなるので、このあたりが s/with で書く限界かもしれません。

一部分だけならもっと短く

区切り文字の前後どちらか一方が欲しいだけなら、s/with より s/before と s/after が簡単です。どちらも最初に現れる区切り文字を使います。

$ echo 'status=active' | cho '(p (s/before $1 "=") (s/after $1 "="))'
status active

複数の部分を組み合わせるなら s/with、前後の片方だけなら s/before か s/after、という使い分けにしています。区切りに正規表現が必要な場合は re/with もあります。

まとめ

s/withを作ったことで、リストを導入せずに、分割した複数の値を扱えるようになりました。

もともとある$1や$2を、文字列の分割にも使えたのが、設計を考えていて面白かったところです。