2024-11-29

Diredでファイルを好きな順に並べる

Web検索すると s キーや C-u s でソート順を変更できるという情報が見つかりますがそういった話ではありません。悪しからず。

Diredではlsやls-lispが認識するオプションによってファイルをソートできますが、ソートのキーに出来るのはあくまでファイルシステムが認識できるもののみです。ファイルシステムの外にある何らかの情報に基づいてファイルをソートするにはどうしたら良いでしょうか。

テキストバッファレベルでのソート

基本的にDiredというのは(良くも悪くも)ファイル情報を並べただけのテキストバッファに過ぎないので、read-onlyを一時的に解除して好きなように並べ替えてしまえば良いのです。ただし、次のことに注意する必要があります。

  • C-x C-q (dired-toggle-read-only)はwdiredを起動してしまうので使えません。 M-x read-only-mode するか、Emacs Lispから (let ((inhibit-read-only t)) ...書き替え... ) しましょう。
  • ファイル部分には見えないテキストプロパティが設定されているのでそれが失われないようにしましょう。
  • ディレクトリの範囲はdired-subdir-alist変数とマーカーで管理されているので、それが壊れないようにしましょう。
  • Diredの一部の機能や拡張はファイル部分にオーバーレイを設定するので、それに配慮しましょう。いったん解除してから編集するか、または、それらが機能する前に割り込んで処理してしまうかです。

試しに M-x read-only-mode の後にファイルの範囲を正確にリージョンで囲って、 M-x sort-lines してみればソートされることが確認できると思います。

外部の情報を元にテキストバッファをソート

例えば何かファイルに対するスコアを列挙したファイル(.file-score.csv)があるとしましょう。

muscat.html,230
melon.html,140
grape.html,185
cherry.html,210
strawberry.html,153

これを読み込むには次のようなEmacs Lispの関数を作れば良いでしょう。

(defun my-dired-sort-read-file-score (dir)
  (ignore-errors
    (with-temp-buffer
      (insert-file-contents (file-name-concat dir ".file-score.csv"))
      (goto-char (point-min))
      (cl-loop until (eobp)
               for (file score) = (split-string (buffer-substring (point) (line-end-position)) ",")
               collect (cons (expand-file-name file dir)
                             (string-to-number score))
               do (forward-line)))))

試しに実行してみると次のようになります。

(my-dired-sort-read-file-score "/products/")
(("/products/muscat.html" . 230)
 ("/products/melon.html" . 140)
 ("/products/grape.html" . 185)
 ("/products/cherry.html" . 210)
 ("/products/strawberry.html" . 153))

これを元にDired上でファイルをソートするには、sort-subr関数を使用すると次のように書けます。

(defun my-dired-sort-region-by-file-score (beg end)
  (when-let ((file-score-alist
              (my-dired-sort-read-file-score (dired-current-directory))))
    (save-restriction
      (narrow-to-region beg end)
      (goto-char beg)
      (sort-subr
       nil            ; REVERSE
       #'forward-line ; NEXTRECFUN
       #'end-of-line  ; ENDRECFUN
       ;; STARTKEYFUN (注意:決してnilを返さないこと)
       (lambda ()
         (alist-get (dired-get-filename nil t) file-score-alist 0 nil #'string=))
       nil            ; ENDKEYFUN
       #'<            ; PREDICATE
       ))))

sort-subrはバッファ内のテキストをソートする汎用的な関数です(Sorting (GNU Emacs Lisp Reference Manual))。引数STARTKEYFUNには通常キーとなるテキストの先頭へ移動する関数を指定しますが、単にキーとなる値を返しても構いません。ここではファイル名に対応するスコアの数値を返しています。キーを数値にしたのでPREDICATEには #'< を指定していますが、デフォルトのままでも大丈夫かもしれません。

試しにテスト用のファイルを用意して、Diredバッファ内でファイルをリージョンで囲い、 M-: (my-dired-sort-region-by-file-score (region-beginning) (region-end)) などとしてみるとちゃんとソートされることが分かります。

一つのディレクトリのファイルリストの範囲

ここまでソートする範囲を手動で指定していましたが、ディレクトリの先頭のファイルと最後のファイルの位置を自動的に求めるにはどうしたら良いでしょうか。

ここで注意する必要があるのが、Diredは一つのバッファ内に複数のディレクトリを表示できるという点です(dired-insert-subdir等)。なのでその一つ一つのディレクトリ毎にファイルリストの範囲を求める必要があります。

最も単純なのはバッファ内の全行をスキャンしてファイルがある所とファイルが無いところを判別していくことです。

(defun my-dired-next-dir-files-region ()
  ;; ファイル名が無いところをスキップ
  (while (and (null (dired-move-to-filename))
              (= (forward-line) 0)))
  (unless (eobp)
    (let ((beg (line-beginning-position)))
      ;; ファイル名があるところをスキップ
      (while (and (dired-move-to-filename)
                  (= (forward-line) 0)))
      (cons beg (line-beginning-position)))))

行にファイル情報があるかどうかはdired-move-to-filenameが非nilを返すかどうかで判別できます。この方法はdired.el内で広く使われています。

そしてファイル情報が連続している範囲を見つけて返すのが上のコードになります。

全行をスキャンするのが嫌ならばdired-subdir-alistを利用することもできます。

(defun my-dired-next-dir-files-region ()
  ;; ファイル名が無いところをスキップ
  (while (and (null (dired-move-to-filename))
              (= (forward-line) 0)))
  (unless (eobp)
    (let ((beg (line-beginning-position)))
      ;; 次のディレクトリの先頭またはEOBへジャンプ
      (goto-char (dired-subdir-max))
      ;; ファイルがある所まで戻る
      (while (and (= (forward-line -1) 0)
                  (not (dired-move-to-filename))))
      ;; その次の行の先頭
      (forward-line)
      (cons beg (point)))))

dired-subdir-max関数は現在のポイントが指し示すディレクトリのバッファ内での末尾位置を返します。dired-subdir-alistには各ディレクトリの末尾の位置がマーカーとして保存されているので、全行をスキャンせずとも高速に割り出すことが出来ます。

ただ、その位置は正確に最後のファイルを指し示していないので、若干の後戻りスキャンが必要になります。

Diredバッファ内の全てのファイルをソートする

以上を組み合わせると次のようなコマンドを仕立てることが出来ます。

(defun my-dired-sort-by-file-score ()
  (interactive nil dired-mode)
  (widen)
  (goto-char (point-min))
  (let ((inhibit-read-only t))
    (while-let ((files-region (my-dired-next-dir-files-region)))
      (my-dired-sort-region-by-file-score (car files-region) (cdr files-region))
      (goto-char (cdr files-region)))))

どのタイミングでソートするか

手動でコマンドを実行したときだけソートされれば良いのであればこれで十分です。しかし何かのコマンドを実行したら常にソートされていて欲しい場合はどうしたら良いでしょうか。

当然Diredバッファが更新されるたびにどこかで自動的にソートする必要があります。

Diredのバッファ更新処理は大ざっぱに言って次のような流れを辿ります。

dired-noselect等
  → dired-revert
     → dired-readin (あるいはdired-insert-old-subdirs)
        → dired-readin-insert
            → dired-insert-directory
                → insert-directory
                    → file-name-handler(insert-directory) or
                       ls-lisp--insert-directory or
                       call-process(sh -c ls)
     → run-hook 'dired-after-readin-hook

最も無難なのがdired-after-readin-hookのタイミングです。このタイミングではバッファの更新が一通り終わって、上のコードで使用している全てのdired関数(dired-move-to-filenamedired-subdir-maxdired-get-filename等)が正常に機能します。ただし、他の拡張もこのタイミングで様々な処理を行うことが多いため、競合には注意した方が良いでしょう。

他のタイミングとしては、一つのディレクトリを挿入するdired-insert-directoryinsert-directoryの直後が考えられるでしょう。ただし、このタイミングでソートをするにはいくつか注意が必要です。

  • 全てのdired関数が使用できない、あるいは正しく動作しないケースがある可能性がある。例えばdired-insert-directoryの直後ではdired-subdir-alistが構築されていないため、ディレクトリのファイルリストの範囲は全行スキャンしなければなりません。dired-move-to-filenameはおそらく大丈夫ですが、dired-get-filenameは問題がありそうです。
  • ファイル名のエスケープ処理に注意。バッファに挿入されるファイル名にはある種のエスケープ処理が施されています。なので、バッファからファイル名を取得するときにはエスケープを解除する処理が必要になります。dired-get-filenameはその辺りの処理をちゃんと行ってくれます。

ls-lispの中でソートするのは魅力的なアイデアでしょう。ls-lispはlsコマンドを使用せずEmacsの機能だけでlsをエミュレートするライブラリです。Windowsや最近ではAndroidでもデフォルトではls-lispが使われるようになっています。ls-lispは内部でdirectory-files-and-attributes関数を呼び出してファイルリストを取得しています。この関数は結果をLispのリストで返すため、うまく介入すればテキストに挿入される前にリストのレベルでソート処理を行えます。具体的にはls-lisp-handle-switches関数の直前はどうでしょうか。file-alistを独自にソートした上でswitchesにUオプションを追加してしまえばls-lispはそれ以上ソートを行いません。欠点はls-lispは使われない場合があるという点でしょう。UNIX環境でlsを使っている場合は当然として、trampなどでリモートディレクトリを参照している場合もls-lispが使われない可能性があります。

el-xmpでXMPプロパティに基づいてソート

というわけで、先日から作っているel-xmpでは、任意のXMPプロパティに基づいてdiredやimage-dired内でファイルやサムネイルをソートできるようになりました。

misohena/el-xmp: Emacs XMP (Extensible Metadata Platform) Library

xmp-setup.elを使用すれば ' S p でプロパティ名を指定してソート、 ' S - で解除できます。私は exif:DateTimeOriginal (撮影日)でソートしたいがためにこの機能を追加しました。

dired上でのソートは概ね上で説明したようなやり口で対処しています。

image-dired上では一つの画像がテキストプロパティが付いた1文字に対応するので、sort-subrを使用して1文字単位でソートしています。

あ、そうそう、結局キャッシュのためにSQLiteでデータベースを作成しました。ついでにサイドカーファイル無しでもプロパティを保存できるようにもしました(設定次第)。ただし、サイドカーファイルが無い分データベースのメンテナンスのために様々なコマンドを駆使する必要が出てきてしまっています。プロパティを付けた後にその対象ファイルを移動したり削除したりした場合、データベース内に記録されたプロパティは対象ファイルが存在しないいわば「はぐれ」状態になってしまいます。それを修正するためのコマンドを色々追加してあります。サイドカーファイルでもはぐれファイルになることはありますが比較的気がつきやすいですし対処もしやすいです。