【Common Lisp】
サブディレクトリに末尾文字列を付け足すコ
マンドを作った
(add-tail-subfolder)

指定ディレクトリ直下のサブディレクトリ全件を一括リネームする小さなツールを作りました。
たとえば ~/P/p/@later 以下に foobarbaz があるとき、末尾文字列 2024 を渡すと foo_2024bar_2024baz_2024 になります。
最終的にSBCLの実行ファイルとして固めるまでを順に説明します。

関連記事

1. 完成コード

まずは、シェルスクリプトで書くなら、

#!/bin/bash
tail=$1
dir=$2

for d in "$dir"/*/; do
  name=$(basename "$d")
  mv "$d" "${d%/}_${tail}"
doneCode language: Bash (bash)

これをCommon Lispで実行ファイルにコンパイルし、コマンドを作ります。

(require :uiop)

(defun renamed-subdir-path (dir tail)
  (let* ((parts (pathname-directory dir))
         (new-name (format nil "~A_~A"
                           (car (last parts))
                           tail)))
    (make-pathname :directory (append (butlast parts)
                                      (list new-name))
                   :name nil
                   :type nil
                   :defaults dir)))

(defun rename-one-subdir (dir tail)
  (let ((to (renamed-subdir-path dir tail)))
    (when (probe-file to)
      (error "Already exists: ~A" to))
    (rename-file dir to)
    (format t "~A -> ~A~%" (subdir-name dir) (subdir-name to))))

(defun add-tail-subfolder (tail path)
  (dolist (dir (uiop:subdirectories
                (uiop:ensure-directory-pathname path)))
    (rename-one-subdir dir tail)))

(defun main ()
  (let ((args (uiop:command-line-arguments)))
    (unless (= (length args) 2)
      (format *error-output* "Usage: add-tail-subfolder TAIL DIRECTORY~%")
      (uiop:quit 1))
    (apply #'add-tail-subfolder args)))

#+script
(main)Code language: Lisp (lisp)

1.1. シェルスクリプトとLispで何が違うか

同じ処理をシェルスクリプトで書いたときの違いは、文字列とリスト構造にあります。

シェルスクリプトはパスを文字列として扱います。
${d%/} でスラッシュを除去し、_${tail} を連結するだけです。
OSのパス操作の作法に乗っているので、ロジックが自然に短くなります。

Lispのコードはパスを構造体として扱いました。
pathname-directory でリストに分解し、butlast で親を残して append で新しい名前を付け足し、make-pathname で再構成します。
文字列操作ではなく、パスの構造を直接操作する書き方です。

この違いは、何を信頼するかの違いです。
シェルスクリプトはOSのパス文字列の形式が一定であることを前提にします。
Lispはその形式を処理系が抽象化してくれることを前提にします。
抽象化の分だけコードは長くなりますが、パスの構成要素を個別に取り出したり変換したりする処理が増えるほど、構造体として持っていることが効いてきます。

2. Common Lispのファイル操作関数

記事内で使っているファイル操作関数をまとめます。

Common Lisp標準のファイル操作関数

関数名引数機能
pathname-directorypathnamepathname のディレクトリ部分をリストとして取り出します。例として /home/user/foo/ なら (:absolute “home” “user” “foo”) のような構造を返します。
make-pathname&key :directory :name :type :defaults などpathname を構成します。記事では、親ディレクトリ部分と新しいディレクトリ名からリネーム後のパスを作っています。
probe-filepathspec指定したファイルまたはディレクトリが存在するか確認します。存在すれば pathname、存在しなければ nil を返します。
rename-filefile new-nameファイルまたはディレクトリをリネームします。記事ではサブディレクトリ名の変更に使っています。

UIOPパッケージのファイル操作関数

関数名引数機能
uiop:ensure-directory-pathnamepathspec指定されたパスをディレクトリとして扱える pathname に整えます。記事では、ユーザーが渡した対象ディレクトリを uiop:subdirectories に渡せる形にしています。
uiop:subdirectoriesdirectory指定ディレクトリ直下のサブディレクトリ一覧を返します。記事では、リネーム対象のサブディレクトリを列挙するために使っています。

2.1. パス名を文字列ではなく構造体として扱う(make-pathname)

まず、リネーム後のパスを組み立て方を考えます。

(defun renamed-subdir-path (dir tail)
  (let* ((parts (pathname-directory dir))
         (new-name (format nil "~A_~A"
                           (car (last parts))
                           tail)))
    (make-pathname :directory (append (butlast parts)
                                      (list new-name))
                   :name nil
                   :type nil
                   :defaults dir)))Code language: Lisp (lisp)

Common Lisp の pathname は、パスを構造体として扱っています。
make-pathnameで生成しているように、ディレクトリ部分は :directory スロットにリストで入っており、/home/user/foo/bar/ なら (:absolute "home" "user" "foo" "bar") という形になります。

ディレクトリ dir のリスト構造は、pathname-directoryで生成できます。
リストの末尾セルをlast で取り、car で値を取り出します。
これで、パスの最後尾のディレクトリ名の文字列が得られます。

末尾要素を除いた親ディレクトリ部分は、butlast で取り、そこに新しい名前を append で付け足せば、新しいパスです。
これを :directory に渡します。
:name nil :type nil を明示するのは、dir が元々ファイルとして認識されていた場合にファイル名部分が混入しないようにするためです。

2.2. リネーム処理(rename-file)

パスが用意できたら rename-file に渡すだけです。

(defun rename-one-subdir (dir tail)
  (let ((to (renamed-subdir-path dir tail)))
    (when (probe-file to)
      (error "Already exists: ~A" to))
    (rename-file dir to)
    (format t "~A -> ~A~%" (subdir-name dir) (subdir-name to))))Code language: Lisp (lisp)

移動先が既に存在すれば error で止めます。
成功したら元の名前と新しい名前を標準出力に出します。

add-tail-subfolder では、uiop:subdirectories でサブディレクトリの一覧を取り、dolist で回します。

(defun add-tail-subfolder (tail path)
  (dolist (dir (uiop:subdirectories 
                (uiop:ensure-directory-pathname path)))
    (rename-one-subdir dir tail)))Code language: Lisp (lisp)

2.3. CLIエントリポイント(main)

コマンドラインから使うために main を作ります。

(defun main ()
  (let ((args (uiop:command-line-arguments)))
    (unless (= (length args) 2)
      (format *error-output* "Usage: add-tail-subfolder TAIL DIRECTORY~%")
      (uiop:quit 1))
    (apply #'add-tail-subfolder args)))Code language: Lisp (lisp)

引数が2つでなければUsageを標準エラーに出して終了します。
引数が揃っていれば add-tail-subfolder に渡すだけなので、apply で展開して呼びます。

3. #+script フィーチャーで実行ファイルとスクリプトを切り替える

ファイル末尾のこの一行が肝心です。

#+script
(main)Code language: Lisp (lisp)

#+ はリーダーマクロで、直後のシンボルが *features* リストに存在するときだけ次の式を読み込みます。
:script フィーチャーが積まれているときだけ (main) が評価されます。

スクリプトとして走らせるときは --eval:script*features* に積んでから --script でロードします。

sbcl --eval '(pushnew :script *features*)' \
     --script add-tail-subfolder.lisp \
     2024 ~/P/p/@laterCode language: Bash (bash)

3.1. SBCLで実行ファイルをコンパイルする

実行ファイルを作るときは :script を積まずに --load します。
#+script のブロックをリーダーが読み飛ばすので (main) は実行されません。
その状態で sb-ext:save-lisp-and-die を呼ぶと、:toplevel に指定した関数がエントリポイントになったバイナリが生成されます。

sbcl --load add-tail-subfolder.lisp \
     --eval '(sb-ext:save-lisp-and-die "add-tail-subfolder" :toplevel #'\''main :executable t)' \
     --quitCode language: Bash (bash)

--load はファイルを評価しても処理系が生き続けるので後続の --eval を連鎖できます。
--script はファイル評価後に自動終了するため、save-lisp-and-die を続けて呼べません。
実行ファイル生成に --load を使う理由はそこにあります。

生成された add-tail-subfolder はSBCLなしで動く単体実行ファイルです。
起動時に :toplevel の関数が呼ばれ、uiop:quit で明示的に終了コードを返します。
同じソースファイルが、フィーチャーの有無だけでスクリプト実行と単体バイナリの両方に対応します。