« ^ »

EmacsとCSVと表

所要時間: 約 7分

EmacsにはCSVファイルを扱うための csv-modecsv-align-mode メジャーモードがある。これはこれで便利ではあるのだが、僕の要求する使い勝手とは微妙に使用感が異なる。今回は、この使い勝手について考える事にする。

最近良くあるケースはWebサービスへデータを投入するために、CSVファイルにデータを記述しておき、そのファイルをアップロードするものだ。このような機構を持つWebサービスはよく見かける。そして、そのCSVのフォーマットのヘッダには日本語が入っており、そして長い。そのようなCSVファイルを扱っていると、今どのデータを編集したのかという事が分からなくなってくる。「Excelを使えばいいのでは?」と思うかもしない。その通りだと思う反面、編集作業はやはりEmacsで行いたいし、Excelで実施したとしても、表示の形式は csv-align-mode でもほとんど同じになる。僕の要求はExcelのような表示ではなくて、SQLでSELECTの結果を詳細表示するような表示になり、その値を編集できる事が望ましい。

Emacsにはそのような表を表示するための tabulated-list-mode が存在する。 tabulated-list-mode を利用し、指定した1行だけを一覧表示するようなメジャーモードを作成すれば良いのではないかと考えた。

まずは tabulated-list-mode の復習をしよう。

テスト用のバッファを作成する。

(setq testing-buf (get-buffer-create "*testing*"))

このバッファにはおそらく fundamental-mode が設定されている。このバッファをカレントバッファにし、 tabulated-list-mode を呼び出す事で、メジャーモードを tabulated-list-mode に変更できる。

(with-current-buffer testing-buf
  (tabulated-list-mode))

おそらく、何も表示はされていないだろう。それは tabulated-list-mode が要求するバッファローカルな変数に値を設定していないからだ。

次に以下のような表を作成してみる。

nameage
alice1
bob2

ヘッダには nameage を設定する。

(with-current-buffer testing-buf
  (setq-local header-line-format nil))

name=、=age の順番にエントリーを配置していく事を指定する。

(with-current-buffer testing-buf
  (setq-local tabulated-list-format [("name" 10 t)
                                     ("age" 10 t)]))

tabulated-list-format に設定したフォーマットの順番に従い、エントリーを配置する。先頭にある nil にはIDの役割を果たす値を設定できる。それにより、そのエントリーを取得したり削除したりできる。ここではそれを行わないのでnilを設定した。

(with-current-buffer testing-buf
  (setq-local tabulated-list-entries '((nil ["alice" "1"])
				       (nil ["bob" "2"]))))

これらを評価すると *testing* バッファは次のように表示される。

https://res.cloudinary.com/symdon/image/upload/v1700214828/blog.symdon.info/1700208810/simple-table.png.png

次に、表示方法について考える。例として nameagepower の列があるCSV を用いる。

name,age,power
alice,1,20
bob,2,10

表にすると以下のようになる。

nameagepower
alice120
bob210

この中の1行のみを、表示し値を確認したり変更したりしたい。 alice の行で言えば、縦方向に nameagepower をラベルと値という形式で表示したい。

LabelValue
namealice
age1
power20

ヘッダー行と現在の行を解析し、その情報を tabulated-list-mode が要求する形式に従い設定する事で、この表示を実現できる。

この形式を表示する tabulated-list-mode の設定を記述してみる。

(with-current-buffer (get-buffer-create "*testing*")
  (tabulated-list-mode)
  (setq-local header-line-format '("Label" "Value"))
  (setq-local tabulated-list-format [("Label" 10 t)
                                     ("Value" 10 t)])
  (tabulated-list-init-header)

  (setq-local tabulated-list-entries '((nil ["name" "alice"])
				       (nil ["age" "1"])
				       (nil ["power" "20"])))
  (tabulated-list-revert)
  )

これを評価すると以下のような表示となる。

https://res.cloudinary.com/symdon/image/upload/v1700219451/blog.symdon.info/1700208810/label-value-table.png

Label はファイルの先頭行、 Value は現在カーソルがある行として扱いたい。

今回はcsv形式のバッファから1行取得し、それをリストとして返す機能だった。完全な実装は後で考える事にし、今は1行の文字列を渡すとカンマで切断したリストを返す関数を実装する。

(defun csv-line-to-list (line)
  (string-split line ","))

この実装には当然問題がある。例えば「20,000円」のような値を持つ列があり、その行を csv-line-to-list に渡すと、 '("20" "000円") のような形に分割されてしまう。ういった枝葉の部分は後で修正すれば良いし、きっとMelpaにもそれに適したパッケージがある。車輪の再発明をしないためにも、良くできたパッケージを利用した方がよい。

忘れてはいけないのは、サードパーティのパッケージを使い過ぎないという事だろう。もっと言うと、中の動きを理解できているコードが今の自分にできる範囲であり、自分にできる事を忘れてはいけないと考えている。もしサードパーティのパッケージのコードの中の動きを理解できていないのであれば、それは今の自分に理解できていないという事だ。

話を戻して実装を進める。先の csv-line-to-list を使い、現在の行をパースし値を設定してみる。

line-beginning-positionline-end-positionbuffer-substring-no-properties を組み合わせて、現在の行を取得する。これらの関数は、カーソルやバッファの状態によって値が変わるため、純粋関数ではない。

(buffer-substring-no-properties
 (line-beginning-position)
 (line-end-position))

これで取得した値を、csvの解析用に定義した関数を渡しリストを得る。

(csv-line-to-list
  (buffer-substring-no-properties
    (line-beginning-position)
    (line-end-position)))

またヘッダー行は先頭行だと考えて、一時的にカーソルを移動し同様の処理を行う。

(csv-line-to-list
  (save-excursion
    (beginning-of-buffer)
    (buffer-substring-no-properties
      (line-beginning-position)
      (line-end-position))))

解析して得た結果を tabulated-list-mode が要求する形で配置する。

(let* ((header-line (save-excursion
		      (beginning-of-buffer)
		      (buffer-substring-no-properties
		       (line-beginning-position)
		       (line-end-position))))
       (data-line (buffer-substring-no-properties
		   (line-beginning-position)
		   (line-end-position)))
       (header-list (csv-line-to-list header-line))
       (data-list (csv-line-to-list data-line))
       (tb-entry-list (mapcar (lambda (val) (list nil val))
  			      (cl-mapcar #'vector header-list data-list))))
  (with-current-buffer (get-buffer-create "*testing*")
    (tabulated-list-mode)
    (setq-local header-line-format '("Label" "Value"))
    (setq-local tabulated-list-format [("Label" 10 t)
                                       ("Value" 10 t)])
    (tabulated-list-init-header)

    (setq-local tabulated-list-entries tb-entry-list)
    (tabulated-list-revert)))

この値は編集作業をしたい。だから、それについても考える事にする。

(with-current-buffer (get-buffer-create "*testing*")
  (tabulated-list-mode)
  (setq-local header-line-format '("Label" "Value"))
  (setq-local tabulated-list-format [("Label" 10 t)
                                     ("Value" 10 t)])
  (tabulated-list-init-header)

  (setq-local tabulated-list-entries '((1 ["name" "alice"])
				       (2 ["age" "1"])
				       (3 ["power" "20"])))
  (tabulated-list-revert))

1番目の値を取得したい場合は、 tabulated-list-get-entry を使う。引数を指定しなければ、カーソルがある行を取得する。

(with-current-buffer (get-buffer "*testing*")
  (tabulated-list-get-entry 1))

挿入する場合は tabulated-list-print-entry を使う。

(with-current-buffer (get-buffer "*testing*")
  (tabulated-list-print-entry 2 ["age" "2"]))

行を削除する場合は tabulated-list-delete-entry を使う。これはカーソルのある行を削除する。

(with-current-buffer (get-buffer "*testing*")
  (tabulated-list-delete-entr))

これらの関数を組み合せると、行を編集できるようになる。

(let* ((id (tabulated-list-get-id))
       (old-entry (tabulated-list-get-entry))
       (new-entry (read--expression "Entry: " (prin1-to-string old-entry))))
  (tabulated-list-delete-entry)
  (tabulated-list-print-entry id new-entry))

これで表示は切り替わるのだが、この表示はテキストプロパティとして設定されているらしい。 tabulated-list-entries の値は変更にならないため、この値をテキストプロパティに書き戻す処理を実装する。

(defun csvrow-line-beginnings ()
  (save-excursion
    (goto-char (point-min))
    (let ((line-beginnings nil))
      (while (not (eobp))
	(push (point) line-beginnings)
	(forward-line 1))
      (nreverse line-beginnings))))

(defun csvrow-properties-of-entries (positions)
  (let ((entries nil))
    (dolist (pos positions)
      (add-to-list
       'entries
       `(,(tabulated-list-get-id pos)
	 ,(get-text-property pos 'tabulated-list-entry))
       t))
    entries))

;;;###autoload
(defun csvrow-reapply-entries ()
  (interactive)
  (setq-local tabulated-list-entries
	      (csvrow-properties-of-entries
	       (csvrow-line-beginnings)))
  (tabulated-list-init-header)
  (tabulated-list-revert)
  (display-buffer "*CSV ROW*"))

編集したなら、そのデータをCSVに書き戻したい。 tabulated-list-entries に反映する処理を整備し、それをCSV形式で出力するコマンドを用意した。


;;;###autoload
(defun csvrow-export ()
  (interactive)
  (let ((data
	 (s-join
	  ","
	  (mapcar (lambda (x) (seq-first (seq-reverse (car (cdr x)))))
		  tabulated-list-entries))))
    (message data)
    (kill-new data)))

だいぶ、形になってきた。パッケージングしたものを張っておく。

;;; csvrow --- A pretty csv row. -*- lexical-binding: t -*-

;; Copyright (C) 2024 TakesxiSximada

;; Author: TakesxiSximada
;; Maintainer: TakesxiSximada
;; Version: 2
;; Package-Version: 20240508.0000
;; Package-Requires: ((emacs "29.1"))
;; Date: 2024-05-08

;; This file is not part of Emacs.

;; This software is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Affero General Public License as
;; published by the Free Software Foundation, either version 3 of the
;; License, or (at your option) any later version.

;; This software is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
;; Affero General Public License for more details.

;; You should have received a copy of the GNU Affero General Public
;; License along with this program.  If not, see
;; <https://www.gnu.org/licenses/>.

(require 's)

;;; Code:

(defun csvrow-line-to-list (line)
  (string-split line ","))

;;;###autoload
(defun csvrow ()
  (interactive)
  (let* ((header-line (save-excursion
			(beginning-of-buffer)
			(buffer-substring-no-properties
			 (line-beginning-position)
			 (line-end-position))))
	 (file-name buffer-file-name)
	 (line-number (line-number-at-pos))
	 (data-line (buffer-substring-no-properties
		     (line-beginning-position)
		     (line-end-position)))
	 (header-list (csvrow-line-to-list header-line))
	 (data-list (csvrow-line-to-list data-line))
	 (tb-entry-list (mapcar (lambda (val) (list nil val))
  				(cl-mapcar #'vector header-list data-list))))
    (with-current-buffer (get-buffer-create "*CSV ROW*")
      (tabulated-list-mode)
      (setq-local csvrow-current-file-name file-name)
      (setq-local csvrow-current-line-number line-number)
      (setq-local header-line-format '("Label" "Value"))
      (setq-local tabulated-list-format [("Label" 40 t)
					 ("Value" 40 t)])
      (tabulated-list-init-header)

      (setq-local tabulated-list-entries tb-entry-list)
      (tabulated-list-revert)
      (display-buffer "*CSV ROW*")
      )))

(defvar csvrow-after-edit-hooks nil
  "CSV Row called after edit")

;;;###autoload
(defun csvrow-edit ()
  (interactive)
  (let* ((id (tabulated-list-get-id))
	 (old-entry (tabulated-list-get-entry))
	 (new-entry (read--expression "Entry: "
				      (prin1-to-string old-entry))))
    (tabulated-list-delete-entry)
    (tabulated-list-print-entry id new-entry))
  (run-hooks 'csvrow-after-edit-hooks))

(defun csvrow-line-beginnings ()
  (save-excursion
    (goto-char (point-min))
    (let ((line-beginnings nil))
      (while (not (eobp))
	(push (point) line-beginnings)
	(forward-line 1))
      (nreverse line-beginnings))))

(defun csvrow-properties-of-entries (positions)
  (let ((entries nil))
    (dolist (pos positions)
      (add-to-list
       'entries
       `(,(tabulated-list-get-id pos)
	 ,(get-text-property pos 'tabulated-list-entry))
       t))
    entries))

;;;###autoload
(defun csvrow-reapply-entries ()
  (interactive)
  (setq-local tabulated-list-entries
	      (csvrow-properties-of-entries
	       (csvrow-line-beginnings)))
  (tabulated-list-init-header)
  (tabulated-list-revert)
  (display-buffer "*CSV ROW*"))

;;;###autoload
(defun csvrow-export ()
  (interactive)
  (csvrow-reapply-entries)
  (let ((data
	 (s-join
	  ","
	  (mapcar (lambda (x) (seq-first (seq-reverse (car (cdr x)))))
		  tabulated-list-entries))))
    (message data)
    (kill-new data)
    data))

;;;###autoload
(defun csvrow-save ()
  (interactive)
  (if-let ((target-file-name csvrow-current-file-name)
	   (target-line-number csvrow-current-line-number)
	   (new-line (csvrow-export)))
      (save-excursion
	(with-temp-buffer
	  (insert-file-contents target-file-name)
	  (goto-line target-line-number)
	  (kill-line)
	  (insert new-line)
	  (write-file target-file-name)))))

(provide 'csvrow)
;;; csvrow.el ends here

./csvrow.el

保存時の挙動を修正する

csvrow.elでは保存の手順がとても煩雑な実装になっている。具体的にはこのような手順で変更済みCSVを保存する。

  1. M-x csvrow で行表示モードに移行する。
  2. M-x csvrow-edit で行編集モードに移行する。
  3. ミニバッファに表示されている行の情報を編集する。
  4. csvrow-export で現在の行表示モードのデータをCSV形式に変換する。
  5. CSVを開き、該当の行を削除し、新しいデータを挿入する。

なぜこんな事になっているのかというと、これを実装した時に他の事で忙しくて、この部分を手抜き実装したからだ。もう何の案件で忙しかったのかすら忘れてしまったけれど、手抜きをしたのは事実だろう。

最近は少しずつだけれど時間に余裕が出てきたため、こういう事にも手を回せるようになっていた。そこでこの機能改修に取り組む。

対象としているファイル名と行番号をバッファローカル変数に保存する

先程の手順の中の4と5は、行表示モード上で C-x C-s によって同時に行いたい。そのためには、行表示モードに移行する際、何行目を表示しているのかを記録しておく必要がある。そこでバッファを開く時、バッファローカル変数として保存する事にした。

;;;###autoload
(defun csvrow ()
  (interactive)
  (let* ((header-line (save-excursion
			(beginning-of-buffer)
			(buffer-substring-no-properties
			 (line-beginning-position)
			 (line-end-position))))
	 (file-name buffer-file-name)
	 (line-number (line-number-at-pos))
	 (data-line (buffer-substring-no-properties
		     (line-beginning-position)
		     (line-end-position)))
	 (header-list (csvrow-line-to-list header-line))
	 (data-list (csvrow-line-to-list data-line))
	 (tb-entry-list (mapcar (lambda (val) (list nil val))
  				(cl-mapcar #'vector header-list data-list))))
    (with-current-buffer (get-buffer-create "*CSV ROW*")
      (tabulated-list-mode)
      (setq-local csvrow-current-file-name file-name)
      (setq-local csvrow-current-line-number line-number)
      (setq-local header-line-format '("Label" "Value"))
      (setq-local tabulated-list-format [("Label" 40 t)
					 ("Value" 40 t)])
      (tabulated-list-init-header)

      (setq-local tabulated-list-entries tb-entry-list)
      (tabulated-list-revert)
      (display-buffer "*CSV ROW*")
      )))

これで *CSV ROW* バッファ上で、どのファイルのどの行を表示ているのか分かるようになった。

保存用の関数を実装する

保存用の関数として csvrow-save を定義する事にした。

;;;###autoload
(defun csvrow-save ()
  (interactive)
  (if-let ((target-file-name csvrow-current-file-name)
	   (target-line-number csvrow-current-line-number)
	   (new-line (csvrow-export)))
      (save-excursion
	(with-temp-buffer
	  (insert-file-contents target-file-name)
	  (goto-line target-line-number)
	  (kill-line)
	  (insert new-line)
	  (write-file target-file-name)))))

この関数をメジャーモードのキーバインドに紐付ければ良い。ただ、今更気が付いたのだが、このモジュールはメジャーモードを定義していなかった。それもやってしまえば良いのだけれど、今回はここまでにしようと思う。