Open5

php-ts-modeを実装する

にゃんだーすわんにゃんだーすわん

EmacsにTree-sitterベースのMajor modeを実装していく。Major modeとはプログラミング言語などをサポートする機能の単位のことだ。

なぜやるのか

理由はいくつかある

  • Emacs 29からTree-sitterベースのメジャーモードに全ベットする風潮になってきた
    • 個別に正規表現ベースのメジャーモードを実装していくのつらい
    • Cc Modeという最強の言語実装フレームワークがベース
      • これを作った人は天才だと思うが、われわれ凡人には手を出せない
  • EmacsにPHPのためのメジャーモードは同梱されていない

個人的にはCc Modeへの愛着はあるのだが、PHP Modeを改善していくにしろ、一旦自分の満足のいくものを完成させるのがいいだろうと考えている。

ここで目指すべきものはPHPに最初から同梱されるメジャーモードのはずだ。

参照すべき最悪の前例はpython-mode-devs / python-modeだ。これはEmacsに同梱されているpython-modeとはまったく互換性のない別実装として今まで続いている。

https://gitlab.com/python-mode-devs/python-mode

似た名前の別実装がいくつもできてユーザーを混乱させるよりも、ほかの hoge-ts-mode と同じようにEmacsに同梱させてしまうのが、もっともユーザーを混乱させないはずだ。そして、既存のPHP Modeユーザーを可能な限り違和感なく軟着陸させることが望ましい。

準備

ここまでやっていれば、以下の式を評価してもエラーにならないはずだ。

  (unless (treesit-ready-p 'php)
    (error "Tree-sitter for PHP isn't available"))
にゃんだーすわんにゃんだーすわん

Tree-sitter vs treesit

先述の通りEmacsのTree-sitterサポートは既にmasterにマージされているのだが、MELPAにあるemacs-tree-sitter/elisp-tree-sitter: Tree-sitter bindings for Emacs Lispemacs-tree-sitter/tree-sitter-langs: Language bundle for Emacs's tree-sitter packageEmacsにビルトインされたTree-sitterサポートとは別物であり互換性もないことに気をつける必要がある。

前述のスターターガイドには以下のようにある

Naming convention

Use tree-sitter for text (documentation, comment), use treesit for symbol (variable, function).

命名規則: テキスト(ドキュメントやコメント)では「tree-sitter」、シンボル(変数や関数)では「treesit」とする

というわけで、本稿においてはインクリメンタルな構文解析ライブラリおよびそのエコシステムを指してTree-sitter、EmacsにビルトインされたTree-sitter連携機能をtreesitと呼ぶことにする。

今回の話題に登場するパッケージは以下の通りになるので混乱しないでほしい。

このようにtreesitベースのメジャーモードはhoge-ts-modeのような命名規則をとっている。もちろんこのtsはTypeScriptのことではない

ちなみにMELPAにあるパッケージでもcasouri/tree-sitter-module: Building script for tree-sitter language definitionsrenzmann/treesit-auto at bac3b9d1d61a4d759f87c80de7be3b808d19cbf6はtreesitに関係している。

にゃんだーすわんにゃんだーすわん

まずメジャーモードを作る

後でおいおい足していきますが、雛形としてはこのようになります。

(eval-when-compile
  (require 'cc-mode)
  (require 'cl-lib))
(require 'treesit)
(require 'c-ts-common)

(defvar php-ts-mode--syntax-table
  (eval-when-compile
    (let ((table (make-syntax-table)))
      (c-populate-syntax-table table)
      (modify-syntax-entry ?_ "w" table)
      (modify-syntax-entry ?`  "\"" table)
      (modify-syntax-entry ?\" "\"" table)
      (modify-syntax-entry ?#  "< b" table)
      (modify-syntax-entry ?\n "> b" table)
      table)))

(define-derived-mode php-ts-mode prog-mode "PHP"
  "Major mode for editing PHP files, powered by tree-sitter."
  :group 'php
  :syntax-table php-ts-mode--syntax-table

  (unless (treesit-ready-p 'php)
    (error "Tree-sitter for PHP isn't available"))

  (treesit-parser-create 'php)

  ;; Comments.
  (c-ts-common-comment-setup)

  (treesit-major-mode-setup))

メジャーモードとしては prog-mode を直接継承している。いまこの状況では php-ts-mode がエラーなく開けると思いますが、特に色はつきません。ただし、M-x treesit-explore-mode を実行することでソースコードと構文木が関連付けられていることがわかります。

メジャーモードテスト用のサンプルコードもここに置いておきます。

<?php

// This is a sample code.

/**
 * @return never
 */
#[\ReturnTypeWillChange]
function f(int|string $v = null): void
{
    $n = (int)$v;
    $datetime = new DateTimeImmutable();

    echo __FILE__;
    if (true) {
        echo "foo.{$v}";
    } else {
        list($a, $b) = array(1, 3);
        echo $datetime->format('Y-m-d H:i:s'), PHP_EOL;
    }

    throw new LogicException('test');
}

?>
<!DOCTYPE html>
<style>
</style>

<h1><?= htmlspecialchars('foo.1', ENT_QUOTES, 'UTF-8')?></h1>

<?php

// Local Variables:
// mode: php-ts
// End:

このように、ファイルに以下のように書いておくことで .emacs.d/init.el などで major-mode-alist を弄らなくても特定のモードが開けるようになるので便利です。

// Local Variables:
// mode: php-ts
// End:
にゃんだーすわんにゃんだーすわん

ハイライトルールを書く

はじめにTree-sitter|Syntax Highlightingを軽く見ておいてください。

http://tree-sitter.github.io/tree-sitter/syntax-highlighting

これをtreesitで使うには treesit-font-lock-settings (= php-ts-mode--font-lock-settings) と treesit-font-lock-feature-list という2種類の変数をセットします。

(define-derived-mode php-ts-mode prog-mode "PHP"

  ;; ... 中略 ...

  ;; Font-lock.
  (setq-local treesit-font-lock-settings php-ts-mode--font-lock-settings)
  (setq-local treesit-font-lock-feature-list
              '((comment definition preprocessor)
                (constant keyword string type variables)
                (annotation expression literal)
                (bracket delimiter operator)))

  (treesit-major-mode-setup))

php-ts-mode--font-lock-settingsqueries/highlights.scmの内容に対応しているので、この定義をEmacs Lispに移植しておくことになります。

まずわかりやすい部分を queries/highlights.scm から抜萃してきましょう。

(php_tag) @tag
"?>" @tag

; Variables

(relative_scope) @variable.builtin

((name) @constant
 (#match? @constant "^_?[A-Z][A-Z\\d_]+$"))
((name) @constant.builtin
 (#match? @constant.builtin "^__[A-Z][A-Z\d_]+__$"))

; Basic tokens

[
  (string)
  (string_value)
  (encapsed_string)
  (heredoc)
  (heredoc_body)
  (nowdoc_body)
] @string
(comment) @comment

; Keywords

"abstract" @keyword
"as" @keyword
"break" @keyword
"case" @keyword
"catch" @keyword
"class" @keyword
"const" @keyword
"continue" @keyword
"declare" @keyword
;; 中略
"while" @keyword

このハイライト定義をEmacs Lispに移植するとこうなります。

(defvar php-ts-mode--font-lock-settings
  (treesit-font-lock-rules
   :language 'php
   :feature 'preprocessor
   `((php_tag) @font-lock-preprocessor-face
     ("?>") @font-lock-preprocessor-face)

   :language 'php
   :feature 'variables
   `((relative_scope) @font-lock-builtin-face

     ((name) @font-lock-constant-face
      (:match ,(rx bos (? "_") (in "A-Z") (in "0-9A-Z_") eos)
              @font-lock-constant-face))
     ((name) @font-lock-builtin-face
      (:match ,(rx-to-string `(: bos (or ,@php-ts-mode--magical-constants) eos))
              @font-lock-builtin-face))

   :language 'php
   :feature 'string
   `([(string)
      (string_value)
      (encapsed_string)
      (heredoc)
      (heredoc_body)
      (nowdoc_body)]
     @font-lock-string-face)

   :language 'php
   :feature 'comment
   `(((comment) @font-lock-doc-face
      (:match ,(rx bos "/**")
              @font-lock-doc-face))
     (comment) @font-lock-comment-face)

   :language 'php
   :feature 'keyword
   `([,@php-ts-mode--keywords] @font-lock-keyword-face))
  "Tree-sitter font-lock settings for `php-ts-mode'.")

php-ts-mode--keywords は別変数に分けてあります。

(defvar php-ts-mode--keywords
  '("abstract" "as" "break" "case" "catch" "class" "const"
    "continue" "declare" "default" "do" "echo" "else"
    "elseif" "enddeclare" "endforeach" "endif" "endswitch"
    "endwhile" "extends" "final" "finally" "foreach"
    "function" "global" "if" "implements" "include_once"
    "include" "insteadof" "interface" "namespace" "new"
    "private" "protected" "public" "require_once" "require"
    "return" "static" "switch" "throw" "trait" "try" "use"
    "while")
  "PHP keywords for tree-sitter font-locking.")

簡単にまとめると、ハイライトルールは以下のように書きます。

  • (treesit-font-lock-rules ...) の中に書く
  • :language 'php :feature 'xxxxx のように書いた次に書く
  • @tag@variable.builtin のようなハイライト名はEmacsのフェイス名の先頭に @ を追加したものに置き換える
    • Emacs組み込みの @font-lock-variable-name-face@font-lock-preprocessor-face など
    • 独自定義したフェイス @php-tag なども使える
  • 元ルールの (#match? @yyy "pattern") はEmacs Lispで (:match "pattern" @yyy) として書く
    • パターンはそのまま書いてもいいが、Emacsの正規表現は独特なので rx を使うことを推奨
  • :feature 'xxxxx はハイライトを有効化・無効化できる単位です
    • treesit-font-lock-feature-list に追加されたものが色付けされます

これらの定義をしたのに色がついていないということは何か設定が間違っています。

  • たとえば php-ts-mode--keywords に余分な文字列を追加するとエラーが出ます。