Emacs Lispのstringのencodingの勉強になった

困っていたこと

いつからかよく分からなかったんだけど、そんな昔じゃないはず。esa.el である時から

Multibyte text in HTTP request

というエラーが出るようになっていた。

nabinno/esa.el: Emacs paste, view and edit modes, this one for esa.io (\( ⁰⊖⁰)/). Like email client

esa.el というのは Emacs で動く esa.io のクライアント。公式が参照しているのは

サードパーティーesaツール - help - docs.esa.io

これの mirror の repository になっている。

で、面倒なので放置していたんだけど、さすがに困る1ので、「今ならLLMの力でなんとかなるのでは?」と思って取り組んでみた。

adviceしてみる

ということで今回は困っていることをプロンプトに OpenCode + GitHub Copilot Model + Claude 4.6 Sonnet を使って問題の解消に取り組んでみた。何回もチャレンジして、esa.el 本体を修正して動くところまでいって、esa.el に手を加えずに対処してと言ったらこういうものが出てきた。

(with-eval-after-load 'esa
  (advice-add 'esa-request-0 :filter-args
              (lambda (args)
                (let ((auth    (nth 0 args))
                      (method  (nth 1 args))
                      (url     (nth 2 args))
                      (cb      (nth 3 args))
                      (params  (nth 4 args)))
                  (list (encode-coding-string auth 'us-ascii)
                        method url cb params)))))

以上の patch を .emacs などで当てれば動く。ほんとは upstream に反映するのがいいんだろうけど、まぁ慌てないで。

うん。動くんだけど、なんか変だなーと思って自分でコードを読んでみた(途中よく分からなくもなっていた。)

原因

上の patch を当てる意味は

  • HTTP body が unibyte string ( utf-8 )
  • HTTP header の auth token が multibyte ( 中身は us-ascii )
  • HTTP header + body で request を concat する時に body も含めて multibyte になってしまう2
    • → length と string-bytes が食い違う → 上記エラーになる

という流れらしい。

Converting Representations (GNU Emacs Lisp Reference Manual)

Emacs chooses the representation for a string based on the text from which it is constructed. The general rule is to convert unibyte text to multibyte text when combining it with other multibyte text, because the multibyte representation is more general and can hold whatever characters the unibyte text has.

だから esa-request 関数の中で実際に request を組み立てる関数に advice を与える。この仕組み自体は面白い。面白いんだが、なんか釈然としない。

どこで何が起きているか

esa.el の中に以下のように request を組み立てているところがあって、

(defun esa-request (method url callback &optional json-or-params)
  (let ((token (esa-check-oauth-token)))
     (esa-request-0
      (format "Bearer %s" token)
      method url callback json-or-params)))
(defun esa-request-0 (auth method url callback &optional json-or-params)
  (let* ((json (and (member method '("POST" "PATCH")) json-or-params))
         (params (and (member method '("GET" "DELETE")) json-or-params))
         (url-request-data (and json (encode-coding-string
                                      (concat (json-encode json) "\n")
                                      'utf-8)))
         (url-request-extra-headers
          `(("Authorization" . ,auth)
            ("Content-Type" . "application/json;charset=UTF-8")))
         (url-request-method method)
         (url-max-redirection -1)
         (url (if params
                  (concat url "?" (esa-make-query-string params))
                url)))
    (url-retrieve url callback (list url json-or-params))))

url-retrieveurl-retrieve-internal になって、いろいろあって

(defun url-http-create-request ()
...
    ;; This was done with a call to `format'.  Concatenating parts has
    ;; the advantage of keeping the parts of each header together and
    ;; allows us to elide null lines directly, at the cost of making
    ;; the layout less clear.
    (setq request
          (concat
...
             ;; Authorization
             auth
...
             ;; Any data
             url-http-data))

みたいに最後 request に関係するものをぐぁーっと concat していく部分があるんだけど、ここで

  • body に utf-8 を含むと上記エラーになる

現象が起きていた。body が us-ascii の時にはこの現象は起きない。

本当の原因と対策

advice は面白い機能なんだけど、なんか妙に複雑じゃない?と思っていろいろ見ていたらハタと気がついた。実は自分の .emacs で esa の access token を外部から取り込むところがあって、これが悪さしていた。

- (setq esa-token (getenv "ESA_TOKEN"))
+ (setq esa-token (encode-coding-string (getenv "ESA_TOKEN") 'us-ascii))

外部から取り込んだだけの token は multibyte ( 中身は us-ascii なんだけど ) になっている。これが concat の際に悪さする。token の中身が us-ascii なのは分かりきっているので encode して unibyte 文字列にしてから token にセットする。これで concat の時点で全部 unibyte になる。

うーん、これ入れたの結構前じゃない…? git log を見ると、10ヶ月前から壊れてました!

実際にはこれだけだと環境変数から取得できなかったときに死ぬので

(setq esa-token (when (getenv "ESA_TOKEN")
		  (encode-coding-string (getenv "ESA_TOKEN") 'us-ascii)))

の方がいい。(実際死んだ)

  1. ローカルに残しておくと雑多になってしまうメモ書きはカジュアルに検索できる、スマホからも読み書きできるので esa.io に残すようにしていたんだけど、それでもやはりPCでの編集はエディタの方がやりやすいので、長らく esa.el を愛用している。 

  2. concat の仕様で multibyte があると全体が multibyte になる 

More