php-ts-modeを実装する
EmacsにTree-sitterベースのMajor modeを実装していく。Major modeとはプログラミング言語などをサポートする機能の単位のことだ。
なぜやるのか
理由はいくつかある
-
Emacs 29からTree-sitterベースのメジャーモードに全ベットする風潮になってきた
- 個別に正規表現ベースのメジャーモードを実装していくのつらい
-
Cc Modeという最強の言語実装フレームワークがベース
- これを作った人は天才だと思うが、われわれ凡人には手を出せない
-
EmacsにPHPのためのメジャーモードは同梱されていない
- 歴史的経緯による → Will GNU Emacs Ever Include php mode?
個人的にはCc Modeへの愛着はあるのだが、PHP Modeを改善していくにしろ、一旦自分の満足のいくものを完成させるのがいいだろうと考えている。
ここで目指すべきものはPHPに最初から同梱されるメジャーモードのはずだ。
参照すべき最悪の前例はpython-mode-devs / python-modeだ。これはEmacsに同梱されているpython-mode
とはまったく互換性のない別実装として今まで続いている。
似た名前の別実装がいくつもできてユーザーを混乱させるよりも、ほかの hoge-ts-mode
と同じようにEmacsに同梱させてしまうのが、もっともユーザーを混乱させないはずだ。そして、既存のPHP Modeユーザーを可能な限り違和感なく軟着陸させることが望ましい。
準備
- Emacs 29をビルドする
- STARTER GUIDE ON WRITTING MAJOR MODE WITH TREE-SITTERを熟読する
-
tree-sitter-phpをビルドする
-
git clone
してsudo make install
とかすればシステムのライブラリパスに入って読み出せるようになる - tree-sitterとか入れないと入らないのかは確認してない
-
ここまでやっていれば、以下の式を評価してもエラーにならないはずだ。
(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 Lispやemacs-tree-sitter/tree-sitter-langs: Language bundle for Emacs's tree-sitter packageはEmacsにビルトインされた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と呼ぶことにする。
今回の話題に登場するパッケージは以下の通りになるので混乱しないでほしい。
- tree-sitter/tree-sitter-php: PHP grammar for tree-sitter - Tree-sitterで構築されたPHP文法定義
- emacs-php/php-ts-mode: A Tree-sitter based major mode for editing PHP codes - treesitをベースに、tree-sitter-phpの定義情報を活用したメジャーモード
このようにtreesitベースのメジャーモードはhoge-ts-modeのような命名規則をとっている。もちろんこのtsはTypeScriptのことではない
ちなみにMELPAにあるパッケージでもcasouri/tree-sitter-module: Building script for tree-sitter language definitionsやrenzmann/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を軽く見ておいてください。
これを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-settings
はqueries/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
なども使える
- Emacs組み込みの
- 元ルールの
(#match? @yyy "pattern")
はEmacs Lispで(:match "pattern" @yyy)
として書く- パターンはそのまま書いてもいいが、Emacsの正規表現は独特なので
rx
を使うことを推奨
- パターンはそのまま書いてもいいが、Emacsの正規表現は独特なので
-
:feature 'xxxxx
はハイライトを有効化・無効化できる単位です-
treesit-font-lock-feature-list
に追加されたものが色付けされます
-
これらの定義をしたのに色がついていないということは何か設定が間違っています。
- たとえば
php-ts-mode--keywords
に余分な文字列を追加するとエラーが出ます。
インデントルールを書く
TBD