Emacsからアプリを開く

この記事は、[Emacs Advent Calendar 2021](https://qiita.com/advent-calendar/2021/emacs)の12日目の記事です。

Emacsからほかのアプリを開く試みは古今東西さまざま行われてきたようです。
しかし、最近のEmacsの初期設定ではほかのアプリを開くのはできないらしい、、、ということでやり方をちょっと調べてみました。

## シェルからアプリを開く

Emacsに限らず、ターミナルやコマンドプロンプト、およびシェルから指定されたファイルをアプリを開くにはどうしたらよいでしょうか？

最近のMacOSやWindows、Linuxなどでは、ターミナルおよびシェルから指定されたファイルをアプリを開くコマンドが用意されています。
ファイルを開くときのアプリは、OS側でファイルの拡張子に応じて設定していることが前提です。

### MacOSの場合

MacOSでは、`open`コマンドを使ってターミナルおよびシェルから指定されたファイルをアプリを開けます。
たとえばカレントディレクトリーにある`sample.pdf`ファイルは、ターミナルから次のコマンドで起動できます。覚えておくと便利。

```
$ open sample.pdf
```

もちろんカレントディレクトリー以外にあるファイルも、開けます。絶対参照と相対参照、どちらでも指定可能です。

```
$ open ../sample.pdf
```

```
$ open /Users/foo/sample.pdf
```

ただし、ちょっと困るのがスペース（空白文字）などの特殊文字を含むファイル名の場合。
たとえばカレントディレクトリーにある`sample 1.pdf`ファイルをターミナルから次のコマンドで起動しようとするとエラーになります。

```
$ open sample 1.pdf
The files /Users/foo/Documents/- and /Users/foo/Documents/株式会社ほげほげ/sample.pdf do not exist.
```

最近ではMicrosoftのOneDriveを使うと、「OneDrive - 株式会社ほげほげ」といったスペースを含む名前のディレクトリーが作られるので、こうしたファイル名を使わざるをえません。このままだと、`open`コマンドでこうしたファイルは開けません。

```
open ../OneDrive - 株式会社ほげほげ/sample.pdf
The files /Users/foo/Documents/- and /Users/foo/Documents/株式会社ほげほげ/sample.pdf do not exist.
```

こういう場合は、空白などの特殊文字の前に `\` をつけてエスケープするか、ファイル名を""（ダブルクォテーション）で囲うとうまく開けます。

```
$ open sample\ 1.pdf
```

```
$ open ../OneDrive\ -\ 株式会社ほげほげ/sample.pdf
```

### Windowsの場合

Windowsでは、startコマンドを使ってコマンドプロンプトからファイルをアプリで開けます。
たとえばカレントディレクトリーにある`sample.pdf`ファイルは、コマンドプロンプトから次のコマンドで起動できます。

```
> start sample.pdf
```

ところが、スペース（空白文字）などの特殊文字を含むファイル名の場合、次のコマンドを実行すると、うまくいきません。

```
> start "sample 1.pdf"
```

`start`コマンドでは最初の引数として指定された""で囲まれた文字列は、ファイル名ではなく、「ウィンドウのタイトルバーに表示されるタイトル」と解釈されるのです。そのため `start "sample.pdf"` では、`sample.pdf`というタイトルのコマンドプロンプトが新たに起動し、`sample.pdf`は開けないのです。うーん、わかりにくい。

""で囲まれたファイル名のファイルを開きたいときは、次のようなコマンドを使います。

```
> start "" "sample.pdf"
```

```
> start "" "sample 1.pdf"
```

```
> start "" "..\OneDrive - 株式会社ほげほげ\sample.pdf"
```

つまり、Windowsでは次のコマンドで名前が[ファイル名]のファイルを開けます。

```
> start "" "[ファイル名]"
```

### Linuxなどの場合

Linuxでは、`xdg-open`コマンドを使ってターミナルおよびシェルから指定されたファイルをアプリを開けます。FreeBSDでも`xdg-open`コマンドは使えるようです。GNU Hurdでも使えそうですが、詳細は不明です。

Linuxなどの場合、MacOSやWindowsとは異なり、さまざまなデスクトップ環境を選択できます。基本的には、デスクトップ環境ごとにファイルをアプリで開くコマンドも変わります。しかし、xdg-openコマンドはそうしたデスクトップ環境の違いを吸収します。

また、MacOSの`open`コマンドやWindowsの`start`コマンドは常に使えるのに対し、Linuxなどでは`xdg-open`コマンドを含むxdg-utilsがインストールされていることが前提条件になります。

たとえば、ターミナルから次のコマンドでPDFファイルを開けます。

```
$ xdg-open sample.pdf
```

```
$ xdg-open ../sample.pdf
```

```
$ xdg-open /Users/foo/sample.pdf
```

MacOSの`open`コマンドと同様、空白などの特殊文字があるときはその前に `\` をつけてエスケープするか、ファイル名を""（ダブルクォテーション）で囲うとうまく開けます。

```
$ xdg-open sample \1.pdf
```

```
$ xdg-open ..\OneDrive\ -\ 株式会社ほげほげ\sample.pdf
```

## Emacsでの実装

### start-process-shell-command関数

Emacsでは、外部コマンドを呼び出す関数が様々用意されています。詳細は、[Emacs Lisp Manual](http://www.gnu.org/software/emacs/manual/html_node/elisp/)の[38 Processes](http://www.gnu.org/software/emacs/manual/html_node/elisp/Processes.html)を参照するといいでしょう。ここでは、指定したシェルコマンドをサブプロセスを作成して非同期に実行させる`start-process-shell-command`関数を使います。

### EmacsからOSごとに異なるコマンドを呼び出す

EmacsでOSごとに異なるコマンドを呼び出すときは、system-type関数を使えます。詳しくは、[init.elの設定をコンピューターごとに分岐させる](https://qiita.com/j8takagi/items/504ccb86921695bdec13)を参照してください。

### OSコマンドインジェクション対策を施す

Emacsからシェルコマンドを実行するときには、OSコマンド・インジェクション対策が必要です。一般的な対策については、[IPA 情報セキュリティ](https://www.ipa.go.jp/security/index.html)にある[安全なウェブサイトの作り方 - 1.2 OSコマンド・インジェクション](https://www.ipa.go.jp/security/vuln/websecurity-HTML-1_2.html)が参考になります。Emacsでの対策については[Emacsからの安全なシェルコマンド実行](https://qiita.com/tadsan/items/17d32514b81f1e8f208a)が参考になります。

今回は、次の対策を施すことにしました。

1. ファイルをアプリで開くOSごとのコマンドとファイル名以外の引数を関数内に記述する
1. ファイル名は実在するかどうかチェックする。実在しない場合はエラー終了
1. ファイル名の中に特別な文字が含まれる文字はエスケープする

特別な文字が含まれる文字をエスケープするために、`file-quote-argument`関数を使います。`file-quote-argument`関数により、Windows（とMS-DOS）の場合は文字列が""で囲まれ、MacOSやLinuxなどそのほかのOSの場合は特別な文字がエスケープされます。

### process-connection-type変数の設定

Linuxなどの`xdg-open`コマンドは、`process-connection-type`変数を`nil`にしないと動作しません。

例えば、次の関数をEmacs上で実行させても`sample.pdf`ファイルは開けません。

```
(start-process-shell-command "emacs-exopen" nil "xdg-open sample.pdf"))
```

次のように一時的に変数`process-connection-type`を`nil`にすることで、`sample.pdf`ファイルを`xdg-open`コマンドで開けます。

```
(let ((process-connection-type nil))
  (start-process-shell-command "emacs-exopen" nil "xdg-open sample.pdf"))
```

情報源は [Emacs Stack Exchange](https://emacs.stackexchange.com) の [Why does xdg-open not work in eshell?](https://emacs.stackexchange.com/questions/19344/why-does-xdg-open-not-work-in-eshell) というやりとり。Qiitaの[この記事](https://qiita.com/angel_p_57/items/ff1c0d054714b7982ca5)や[この記事](https://qiita.com/toshihirock/items/22de12f99b5c40365369)を見ても、なぜこうなるかは理解できませんでしたが。

なお、MacOSの`open`やWindowsの`start`では、`process-connection-type`の値に関わらずEmacsからファイルを開けます。

### exopen-file関数

以上の点を考慮して作成したのが次の`exopen-file`関数です。実行時、日時と実行するコマンド文字列をメッセージとして（*Message*バッファに）出力するようにしました。

```
;;; ファイルを外部プログラムでオープン
(defun exopen-file (file)
  "Open a file with external program."
  ;;; 指定したファイルが実在しない場合はエラー終了
  (unless (file-exists-p file)
    (error "%s: File does not exist." file))
  (let (
        ;; システム別の外部プログラムオープンコマンド
        (opencmd-alist
         '(
           (gnu/linux . ("xdg-open"))
           (gnu/kfreebsd . ("xdg-open"))
           (darwin . ("open"))
           (windows-nt . ("start" "\"\""))))
        ;; xdg-open用の設定
        ;; 参照: https://emacs.stackexchange.com/questions/19344/why-does-xdg-open-not-work-in-eshell
        (process-connection-type nil)
        opencmd cmdstr)
    (unless (car (setq opencmd (cdr (assoc system-type opencmd-alist))))
      (error "There is no external open command on this `%s' system." system-type))
    (setq opencmd (append opencmd (list (shell-quote-argument file))))
    (setq cmdstr (mapconcat 'identity opencmd " "))
    (message "exopen at %s: %s" (format-time-string "%Y/%m/%d %H:%M:%S") cmdstr)
    (start-process-shell-command "emacs-exopen" nil cmdstr)))
```

### exopen-mode.el

ファイルを外部プログラムで開く`exopen-file`関数があれば、次のようなプログラムは比較的かんたんに作れます。

- バッファで開いているファイルを外部プログラムで開く
- 指定したファイルと同名で拡張子が異なるファイルを外部プログラムで開く。たとえば`aaa.tex`ファイルを編集しているときに、`aaa.pdf`ファイルをプレビューやAdobe Readerで開く
- 現在のディレクトリー（フォルダー）をFinderやエクスプローラーで開く
- diredからファイルを外部プログラムで開く

自作の [exopen-mode.el](https://github.com/j8takagi/.emacs.d/blob/master/site-lisp/exopen/exopen.el) は、`exopen-file`関数に加えてこうした関数たちを定義しています。

こうして定義した関数に、`init.el`ファイルなどでキーを割り付けるとなかなか便利です。たとえば私は、diredでは`r`キーでファイルを外部プログラムで開けるようにしています。

## まとめ

MacOS、Windows、Linuxなどでは、MacOSの`open`のようなコマンドが用意されていて、こうしたコマンドをEmacsから呼び出す関数を作ると重宝します。ただし、OSインジェクションなどのセキュリティには気をつけるようにしましょう。

それでは、良いクリスマスを！