From 482ba416dc8c184dda6c01dbb46e8ec3b4fd2832 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 3 Aug 2026 21:20:49 +0900 Subject: [PATCH 01/12] Fix typos and stale examples in php-ide Commentary The custom-set-variables example used invalid quoting for `php-ide-features' and the per-project snippet, and the "no IDE support" note referenced a variable name that never existed (`php-ide-feature`). --- lisp/php-ide-phpactor.el | 2 +- lisp/php-ide.el | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lisp/php-ide-phpactor.el b/lisp/php-ide-phpactor.el index 82ffe33f..52c91f09 100644 --- a/lisp/php-ide-phpactor.el +++ b/lisp/php-ide-phpactor.el @@ -111,7 +111,7 @@ ;;;###autoload (defun php-ide-phpactor-deactivate () - "Dectivate PHP-IDE using phpactor.el." + "Deactivate PHP-IDE using phpactor.el." (interactive) (local-unset-key [remap xref-find-definitions]) (local-unset-key [remap xref-pop-marker-stack]) diff --git a/lisp/php-ide.el b/lisp/php-ide.el index 8880970d..99117c6e 100644 --- a/lisp/php-ide.el +++ b/lisp/php-ide.el @@ -55,7 +55,7 @@ ;; ;; ## Configuration ;; -;; Put follows code into your .emacs (~/.emacs.d/init.el) file: +;; Put the following code into your .emacs (~/.emacs.d/init.el) file: ;; ;; (defun init-php-mode-setup () ;; (add-hook 'hack-local-variables-hook #'php-ide-mode t t)) @@ -71,7 +71,7 @@ ;; ;; (with-eval-after-load 'php-ide ;; (custom-set-variables -;; '(php-ide-features . 'eglot) ;; and/or 'none, 'phpactor, 'lsp-mode +;; '(php-ide-features '(eglot)) ;; and/or '(none), '(phpactor), '(lsp-mode) ;; '(php-ide-eglot-executable "psalm-language-server") ;; or "intelephense", '("php" "vendor/bin/path/to/server") ;; ;; If you want to hide php-ide-mode from the mode line, set an empty string ;; '(php-ide-mode-lighter "")) @@ -79,11 +79,12 @@ ;; (add-hook 'php-mode-hook #'init-php-mode-setup) ;; (add-hook 'php-ide-mode-functions #'init-php-ide-mode-setup)) ;; -;; If you don't enable IDE support by default, set '(php-ide-feature 'none) +;; If you don't want to enable any IDE support by default, leave `php-ide-features' +;; unset (its default, nil) or set it to '(none) explicitly. ;; ;; ### For per project configuration ;; -;; Put follows code into .dir-locals.el in project directory: +;; Put the following code into .dir-locals.el in project directory: ;; ;; ((nil (php-project-root . git) ;; (php-ide-features . (lsp-mode)))) From f7d2be35d30205a7a75a5928c86e581bb9467c09 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 3 Aug 2026 21:21:33 +0900 Subject: [PATCH 02/12] Fix php-ide feature (de)activation and wire up Eglot integration - phpactor's :deactivate pointed at php-ide-phpactor-activate instead of php-ide-phpactor-deactivate, so turning php-ide-mode off actually re-activated Phpactor - lsp-mode's :deactivate called lsp-workspace-shutdown, which requires a WORKSPACE argument and errored on every deactivation; switched to the buffer-scoped lsp-disconnect - php-ide-eglot-server-program used the assq cons cell itself instead of its cdr, so any symbolic php-ide-eglot-executable value (e.g. intelephense, phpactor) produced a malformed command list - Add php-ide-eglot-activate, which buffer-locally registers php-ide-eglot-executable into eglot-server-programs; previously the variable had no effect on Eglot at all --- lisp/php-ide.el | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/lisp/php-ide.el b/lisp/php-ide.el index 99117c6e..c01826b7 100644 --- a/lisp/php-ide.el +++ b/lisp/php-ide.el @@ -112,16 +112,21 @@ :deactivate (lambda () t)) (phpactor :test (lambda () (and (require 'phpactor nil t) (featurep 'phpactor))) :activate php-ide-phpactor-activate - :deactivate php-ide-phpactor-activate) + :deactivate php-ide-phpactor-deactivate) (eglot :test (lambda () (and (require 'eglot nil t) (featurep 'eglot))) - :activate eglot-ensure + :activate php-ide-eglot-activate + ;; `eglot--managed-mode-off' is Eglot's own internal (and unexported) function, + ;; but it is the only operation that turns Eglot off for just the current buffer + ;; without shutting down a server that other buffers may still be using. The + ;; public `eglot-shutdown' always kills the whole server, which would be a much + ;; more disruptive (and asymmetric) deactivation than `php-ide-eglot-activate'. :deactivate eglot--managed-mode-off) (lsp-bridge :test (lambda () (and (require 'lsp-bridge nil t) (featurep 'lsp-bridge))) :activate (lambda () (lsp-bridge-mode +1)) :deactivate (lambda () (lsp-bridge-mode -1))) (lsp-mode :test (lambda () (and (require 'lsp nil t) (featurep 'lsp))) :activate lsp - :deactivate lsp-workspace-shutdown))) + :deactivate lsp-disconnect))) (defvar php-ide-lsp-command-alist '((intelephense "intelephense" "--stdio") @@ -165,11 +170,37 @@ (cond ((stringp php-ide-eglot-executable) (list php-ide-eglot-executable)) ((listp php-ide-eglot-executable) php-ide-eglot-executable) - ((when-let* ((command (assq php-ide-eglot-executable php-ide-lsp-command-alist))) + ((when-let* ((command (cdr (assq php-ide-eglot-executable php-ide-lsp-command-alist)))) (cond ((functionp command) (funcall command)) ((listp command) command)))))) +(defvar php-ide-eglot-managed-modes '(php-mode phps-mode php-ts-mode) + "Major modes for which `php-ide-eglot-activate' overrides +`eglot-server-programs'.") + +(defun php-ide-eglot--contact-function (&optional _interactive _project) + "CONTACT function registered into `eglot-server-programs' by php-ide. +Ignores the INTERACTIVE and PROJECT arguments Eglot may pass; see +`php-ide-eglot-server-program' for the actual command lookup." + (php-ide-eglot-server-program)) + +;;;###autoload +(defun php-ide-eglot-activate () + "Activate Eglot for `php-ide-mode', honoring `php-ide-eglot-executable'. + +When `php-ide-eglot-executable' is set, this buffer-locally prepends +an entry to `eglot-server-programs' so Eglot uses it instead of its +own bundled default for PHP. Buffers where `php-ide-eglot-executable' +is unset are unaffected and keep using Eglot's default." + (when (and php-ide-eglot-executable + (not (eq (cdr (assoc php-ide-eglot-managed-modes eglot-server-programs)) + #'php-ide-eglot--contact-function))) + (setq-local eglot-server-programs + (cons (cons php-ide-eglot-managed-modes #'php-ide-eglot--contact-function) + eglot-server-programs))) + (eglot-ensure)) + (defcustom php-ide-mode-lighter " PHP-IDE" "A symbol of PHP-IDE feature." :tag "PHP-IDE Mode Lighter" From 799debcc883d06a949beb069fea7489d7ba8499f Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 3 Aug 2026 21:21:53 +0900 Subject: [PATCH 03/12] Harden php-ide safety and smooth out its buffer-facing UX - Restrict :safe on php-ide-features/php-ide-eglot-executable to PHP-IDE's own known feature symbols and bundled executable presets, and drop :safe from php-ide-mode-functions entirely; previously these let a .dir-locals.el silently run an attacker-chosen command or Lisp function without Emacs's usual confirmation - php-ide-mode now accepts php-ide-features set to a bare symbol, not just a list, matching what the Commentary already showed - php-ide-turn-on no longer signals a user-error when php-ide-features is unset; it is now a silent no-op, so it is safe to add unconditionally to hack-local-variables-hook - Remove now-unreachable dead code in php-ide-mode's activation loop and rename php-ide--avilable-features (typo) to php-ide--available-features - Add php-ide-set-feature and php-ide-status commands to interactively choose/enable an available feature and report the current state --- lisp/php-ide.el | 85 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/lisp/php-ide.el b/lisp/php-ide.el index c01826b7..b57897c5 100644 --- a/lisp/php-ide.el +++ b/lisp/php-ide.el @@ -148,8 +148,11 @@ :type `(set ,@(mapcar (lambda (feature) (list 'const (car feature))) php-ide-feature-alist) symbol) + ;; Only accept feature symbols already known to `php-ide-feature-alist' as safe + ;; for .dir-locals.el; an arbitrary symbol here could name a feature added by + ;; some future or third-party extension with its own (unvetted) side effects. :safe (lambda (v) (cl-loop for feature in (if (listp v) v (list v)) - always (symbolp feature)))) + always (assq feature php-ide-feature-alist)))) ;;;###autoload (defcustom php-ide-eglot-executable nil @@ -159,10 +162,14 @@ (const intelephense) (const phpactor) string (repeat string)) - :safe (lambda (v) (cond - ((stringp v) (file-exists-p v)) - ((listp v) (cl-every #'stringp v)) - ((assq v php-ide-lsp-command-alist))))) + ;; Only a symbol naming one of the bundled presets in `php-ide-lsp-command-alist' + ;; is safe for .dir-locals.el: the actual command is then fully determined by + ;; this package, not by the (untrusted) directory-local value. A literal string + ;; or argument list lets the directory choose the executable/arguments outright, + ;; which `php-ide-eglot-server-program' would later pass straight to + ;; `start-process' — that must go through Emacs's normal unsafe-variable + ;; confirmation prompt rather than apply silently. + :safe (lambda (v) (assq v php-ide-lsp-command-alist))) ;;;###autoload (defun php-ide-eglot-server-program () @@ -213,28 +220,33 @@ is unset are unaffected and keep using Eglot's default." Notice that two arguments (FEATURE ACTIVATE) are given. FEATURE: A symbol, like \\='lsp-mode. -ACTIVATE: T is given when activeting, NIL when deactivating PHP-IDE." +ACTIVATE: T is given when activating, NIL when deactivating PHP-IDE." :tag "PHP-IDE Mode Functions" :type '(repeat function) - :safe (lambda (functions) - (and (listp functions) (cl-every #'functionp functions)))) + ;; Deliberately has no :safe predicate. This variable holds functions that + ;; `php-ide-mode' calls automatically, so a directory-local value naming an + ;; arbitrary (but already-`fboundp') function would let any repo run code in + ;; the visitor's Emacs just by having them open a file; that must always go + ;; through Emacs's normal unsafe-variable confirmation, never apply silently. + ) ;;;###autoload (define-minor-mode php-ide-mode "Minor mode for integrate IDE-like tools." :lighter php-ide-mode-lighter - (let ((ide-features php-ide-features)) + (let ((ide-features (if (listp php-ide-features) php-ide-features (list php-ide-features)))) (when-let* ((unavailable-features (cl-loop for feature in ide-features unless (assq feature php-ide-feature-alist) collect feature))) (user-error "%s includes unavailable PHP-IDE features. (available features are: %s)" ide-features (mapconcat (lambda (feature) (concat "'" (symbol-name feature))) - (php-ide--avilable-features) ", "))) + (php-ide--available-features) ", "))) + ;; Every feature in IDE-FEATURES is guaranteed to be in `php-ide-feature-alist' here, + ;; because the loop above already signals a `user-error' otherwise. (cl-loop for feature in ide-features - for ide-plist = (cdr-safe (assq feature php-ide-feature-alist)) - do (if (null ide-plist) - (message "Please set `php-ide-feature' variable in .dir-locals.el or custom variable") + for ide-plist = (cdr (assq feature php-ide-feature-alist)) + do (progn (run-hook-with-args 'php-ide-mode-functions feature php-ide-mode) (if php-ide-mode (php-ide--activate-buffer feature ide-plist) @@ -242,10 +254,14 @@ ACTIVATE: T is given when activeting, NIL when deactivating PHP-IDE." ;;;###autoload (defun php-ide-turn-on () - "Turn on PHP IDE-FEATURES and execute `php-ide-mode'." - (unless php-ide-features - (user-error "No PHP-IDE feature is installed. Install the lsp-mode, lsp-bridge, eglot or phpactor package")) - (php-ide-mode +1)) + "Turn on `php-ide-mode' if `php-ide-features' is set, otherwise do nothing. + +Unlike calling `php-ide-mode' directly, this never signals an error when +`php-ide-features' is unset, so it is safe to add unconditionally to +`php-mode-hook' or `hack-local-variables-hook'; buffers/projects that +never configure `php-ide-features' are silently left alone." + (when php-ide-features + (php-ide-mode +1))) (defun php-ide--activate-buffer (name ide-plist) "Activate php-ide implementation by NAME and IDE-PLIST." @@ -257,11 +273,44 @@ ACTIVATE: T is given when activeting, NIL when deactivating PHP-IDE." "Deactivate php-ide implementation by IDE-PLIST." (funcall (plist-get ide-plist :deactivate))) -(defun php-ide--avilable-features () +(defun php-ide--available-features () "Return list of available PHP-IDE features." (cl-loop for (ide . plist) in php-ide-feature-alist if (funcall (plist-get plist :test)) collect ide)) +;;;###autoload +(defun php-ide-set-feature (feature) + "Set `php-ide-features' to FEATURE for the current buffer and enable it. + +Interactively, prompt among the PHP-IDE features currently available on +this system (see `php-ide--available-features'); features whose backing +package (lsp-mode, lsp-bridge, Eglot or phpactor.el) is not installed +are not offered. + +This sets `php-ide-features' buffer-locally, so the choice does not +persist beyond the current buffer; put a matching entry in +\".dir-locals.el\" (or your `php-mode-hook') to make it stick." + (interactive + (let ((available (php-ide--available-features))) + (unless available + (user-error "No PHP-IDE feature is available. Install lsp-mode, lsp-bridge, eglot or phpactor")) + (list (intern (completing-read "PHP-IDE feature: " + (mapcar #'symbol-name available) nil t))))) + (when php-ide-mode + (php-ide-mode -1)) + (setq-local php-ide-features (list feature)) + (php-ide-mode +1)) + +;;;###autoload +(defun php-ide-status () + "Show `php-ide-mode' status for the current buffer in the echo area." + (interactive) + (let ((configured (if (listp php-ide-features) php-ide-features (list php-ide-features)))) + (message "PHP-IDE: %s (configured: %s; available on this system: %s)" + (if php-ide-mode "on" "off") + (if configured (mapconcat #'symbol-name configured ", ") "none") + (or (mapconcat #'symbol-name (php-ide--available-features) ", ") "none")))) + (provide 'php-ide) ;;; php-ide.el ends here From 5856cca5cf9b4f7ec93cfcb704f889293a09476a Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 3 Aug 2026 21:21:59 +0900 Subject: [PATCH 04/12] Document php-ide in README/README.ja Add a PHP-IDE section covering configuration, the interactive commands (php-ide-mode, php-ide-turn-on, php-ide-set-feature, php-ide-status), per-project setup, and which values are safe in .dir-locals.el. --- README.ja.md | 33 +++++++++++++++++++++++++++++++++ README.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/README.ja.md b/README.ja.md index d9bc528f..83b9ebdf 100644 --- a/README.ja.md +++ b/README.ja.md @@ -75,6 +75,39 @@ M-x package-install php-mode (php-project-coding-style . psr2))) ``` +### PHP-IDE: LSPクライアントおよびPhpactorとの連携 + +`php-ide`(`php-ide.el`)は、PHP Modeと[Eglot](https://github.com/joaotavora/eglot)・[lsp-mode](https://github.com/emacs-lsp/lsp-mode)・[lsp-bridge](https://github.com/manateelazycat/lsp-bridge)・[Phpactor](https://github.com/emacs-php/phpactor.el)のようなIDE的機能を橋渡しする**実験的**な機能です。これらの機能自体を実装するものではなく、選んだものを`php-ide-mode`という単一のマイナーモード経由で有効化・無効化するだけです。詳細な仕様は`php-ide.el`冒頭のCommentaryを参照してください。 + +```lisp +(defun my-php-mode-init () + (add-hook 'hack-local-variables-hook 'php-ide-turn-on nil t)) + +(with-eval-after-load 'php-ide + (custom-set-variables + '(php-ide-features '(eglot)) ;; '(phpactor)、'(lsp-mode)、'(lsp-bridge)も可 + '(php-ide-eglot-executable 'intelephense) ;; 'phpactor、パス文字列、文字列のリストも可 + '(php-ide-mode-lighter ""))) ;; モードラインからPHP-IDEを隠す +``` + +`php-ide-turn-on`は`php-ide-features`が未設定のときは何もしないため、上記のように無条件にフックへ追加しても安全です。上記のようにグローバルに、または下記のようにプロジェクト単位で`php-ide-features`を設定するまでPHP-IDEはオフのままです。 + +`php-ide`読み込み後に使えるコマンド: + +* `M-x php-ide-mode` — `php-ide-features`に基づき、現在のバッファでPHP-IDEをトグルする。 +* `M-x php-ide-turn-on` — 同様だが、`php-ide-features`が未設定でもエラーにならない。 +* `M-x php-ide-set-feature` — このシステムで実際に利用可能な(対応パッケージがインストールされている)機能から対話的に選び、現在のバッファで有効化する。 +* `M-x php-ide-status` — PHP-IDEが有効かどうか、設定内容、利用可能な機能を表示する。 + +#### プロジェクト単位のPHP-IDE設定 + +```lisp +((nil (php-project-root . git) + (php-ide-features . (eglot)))) +``` + +`php-ide-features`と`php-ide-eglot-executable`は、PHP-IDE組み込みの機能名やバンドル済み実行ファイルのプリセット(`intelephense`、`phpactor`など)を指す場合に限り、`.dir-locals.el`での設定が安全とみなされます。それ以外の値——生の実行ファイルパス、明示的なコマンド引数、カスタムの`php-ide-mode-functions`フックなど——は、これまで通りEmacsの「危険な変数」に対する通常の確認を経ます。そうしないと、ファイルを開くだけで任意のリポジトリが任意のコマンド(またはLisp関数)をあなたのEmacs上で実行できてしまうためです。 + ### `project.el`・Projectileとの連携 `php-project-get-root-dir`は、まずPHP固有のマーカー(`.projectile`、`composer.json`/`composer.lock`、続いてVCSディレクトリ)を探索します。モノレポではパッケージ単位の`vendor/autoload.php`やコーディングスタイルが`php-mode`にとって重要なため、VCSルートより`composer.json`を優先します。これらのマーカーが見つからない場合は`project-current`にフォールバックするので、任意の[`project.el`](https://www.gnu.org/software/emacs/manual/html_node/emacs/Projects.html)バックエンドが検出に寄与できます。 diff --git a/README.md b/README.md index 178257b8..b67db77f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,39 @@ You can add project-specific settings by creating a `.dir-locals.el` or `.dir-lo (php-project-coding-style . psr2))) ``` +### PHP-IDE: integrating LSP clients and Phpactor + +`php-ide` (`php-ide.el`) is an **experimental** bridge between PHP Mode and IDE-like tools: [Eglot](https://github.com/joaotavora/eglot), [lsp-mode](https://github.com/emacs-lsp/lsp-mode), [lsp-bridge](https://github.com/manateelazycat/lsp-bridge), and [Phpactor](https://github.com/emacs-php/phpactor.el). It does not implement any of these features itself — it only activates or deactivates whichever one(s) you choose, through a single `php-ide-mode` minor mode. See the Commentary at the top of `php-ide.el` for the full reference. + +```lisp +(defun my-php-mode-init () + (add-hook 'hack-local-variables-hook 'php-ide-turn-on nil t)) + +(with-eval-after-load 'php-ide + (custom-set-variables + '(php-ide-features '(eglot)) ;; and/or '(phpactor), '(lsp-mode), '(lsp-bridge) + '(php-ide-eglot-executable 'intelephense) ;; or 'phpactor, a path string, or a list of strings + '(php-ide-mode-lighter ""))) ;; hide PHP-IDE from the mode line +``` + +`php-ide-turn-on` does nothing when `php-ide-features` is unset, so it is safe to add unconditionally as above — PHP-IDE stays off until a feature is configured, either globally as above or per project below. + +Useful commands once `php-ide` is loaded: + +* `M-x php-ide-mode` — toggle PHP-IDE for the current buffer, using `php-ide-features`. +* `M-x php-ide-turn-on` — same, but never errors when `php-ide-features` is unset. +* `M-x php-ide-set-feature` — pick, interactively, one of the features actually available on this system (i.e. whose backing package is installed) and enable it for the current buffer. +* `M-x php-ide-status` — report whether PHP-IDE is on, what is configured, and what is available. + +#### Per-project PHP-IDE configuration + +```lisp +((nil (php-project-root . git) + (php-ide-features . (eglot)))) +``` + +`php-ide-features` and `php-ide-eglot-executable` are only treated as safe for `.dir-locals.el` when they name one of PHP-IDE's own built-in features or bundled executable presets (e.g. `intelephense`, `phpactor`). Anything else — a raw executable path, explicit command arguments, or a custom `php-ide-mode-functions` hook — still goes through Emacs's normal confirmation for risky directory-local variables, since applying those silently would let any repository run an arbitrary command (or Lisp function) in your Emacs just by having you open a file in it. + ### Integration with `project.el` and Projectile `php-project-get-root-dir` first looks for a PHP-specific marker (`.projectile`, `composer.json`/`composer.lock`, then a VCS directory). Preferring `composer.json` over the VCS root matters in monorepos, where per-package `vendor/autoload.php` and coding styles are what `php-mode` cares about. When none of those markers is found, it now falls back to `project-current`, so any [`project.el`](https://www.gnu.org/software/emacs/manual/html_node/emacs/Projects.html) backend can contribute detection: From e8d58770b2a57e091b94775fcecd8336a6b2170a Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 3 Aug 2026 21:22:05 +0900 Subject: [PATCH 05/12] Add regression tests for php-ide Cover php-ide-eglot-server-program's resolution of every php-ide-eglot-executable shape, the feature-alist's :test/:activate/ :deactivate arity contract, the phpactor activate/deactivate fix, bare -symbol php-ide-features, php-ide-turn-on's no-op behavior, the new php-ide-set-feature/php-ide-status commands, the :safe predicates, and php-ide-eglot-activate's buffer-local eglot-server-programs registration. --- tests/php-mode-test.el | 128 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/tests/php-mode-test.el b/tests/php-mode-test.el index 3696a443..4e586d36 100644 --- a/tests/php-mode-test.el +++ b/tests/php-mode-test.el @@ -36,6 +36,7 @@ (require 'php-mode) (require 'php-mode-debug) (require 'php-project) +(require 'php-ide) (require 'ert) (require 'cl-lib) (require 'imenu) @@ -959,6 +960,133 @@ project setting this variable gets a confirmation prompt anyway." (should (string-match-p "OK" (buffer-string))) (should (eq 0 status)))))) +;;; php-ide tests + +(ert-deftest php-ide-test-eglot-server-program () + "`php-ide-eglot-server-program' should resolve every shape of +`php-ide-eglot-executable' without touching Eglot itself." + (let ((php-ide-eglot-executable nil)) + (should (null (php-ide-eglot-server-program)))) + (let ((php-ide-eglot-executable "psalm-language-server")) + (should (equal '("psalm-language-server") (php-ide-eglot-server-program)))) + (let ((php-ide-eglot-executable '("php" "vendor/bin/path/to/server"))) + (should (equal '("php" "vendor/bin/path/to/server") (php-ide-eglot-server-program)))) + (let ((php-ide-eglot-executable 'intelephense)) + (should (equal '("intelephense" "--stdio") (php-ide-eglot-server-program)))) + (let ((php-ide-eglot-executable 'phpactor)) + (should (equal (list (if (fboundp 'phpactor--find-executable) + (phpactor--find-executable) + "phpactor") + "language-server") + (php-ide-eglot-server-program))))) + +(ert-deftest php-ide-test-feature-alist-arity () + "Regression test: `:test' must always be a callable 0-arg predicate, and +for every PHP-IDE feature actually available in this Emacs, `:activate' +and `:deactivate' must be callable with zero arguments too, since +`php-ide--activate-buffer'/`php-ide--deactivate-buffer' always `funcall' +them without arguments. (Features whose backing package, e.g. lsp-mode +or Eglot, is not installed are skipped for the :activate/:deactivate +check, since their symbols are not `fboundp' until that package loads.)" + (dolist (entry php-ide-feature-alist) + (let* ((ide-plist (cdr entry)) + (test-fn (plist-get ide-plist :test))) + (should (functionp test-fn)) + (should (eq 0 (car (func-arity test-fn)))) + (when (funcall test-fn) + (dolist (key '(:activate :deactivate)) + (let ((fn (plist-get ide-plist key))) + (should (functionp fn)) + (should (eq 0 (car (func-arity fn)))))))))) + +(ert-deftest php-ide-test-phpactor-activate-deactivate-not-swapped () + "Regression test: the `phpactor' feature's `:deactivate' must be +`php-ide-phpactor-deactivate', not (as it once was, by copy-paste) +`php-ide-phpactor-activate'." + (let ((ide-plist (cdr (assq 'phpactor php-ide-feature-alist)))) + (should (eq #'php-ide-phpactor-deactivate (plist-get ide-plist :deactivate))))) + +(ert-deftest php-ide-test-features-accepts-bare-symbol () + "Regression test: `php-ide-features' set to a bare symbol (as shown in +`php-ide.el's own Commentary) must not signal wrong-type-argument." + (with-temp-buffer + (php-mode) + (setq-local php-ide-features 'none) + (php-ide-mode +1) + (should php-ide-mode) + (php-ide-mode -1) + (should-not php-ide-mode))) + +(ert-deftest php-ide-test-turn-on-is-noop-when-unconfigured () + "Regression test: `php-ide-turn-on' must not signal an error (and must +not turn `php-ide-mode' on) when `php-ide-features' is unset, so it is +safe to add unconditionally to `hack-local-variables-hook'." + (with-temp-buffer + (php-mode) + (should-not php-ide-features) + (php-ide-turn-on) + (should-not php-ide-mode))) + +(ert-deftest php-ide-test-set-feature-and-status () + "`php-ide-set-feature' should set `php-ide-features' buffer-locally and +turn `php-ide-mode' on; `php-ide-status' should not error." + (with-temp-buffer + (php-mode) + (php-ide-set-feature 'none) + (should (equal '(none) php-ide-features)) + (should php-ide-mode) + ;; Re-selecting the same feature while already on must not error. + (php-ide-set-feature 'none) + (should php-ide-mode) + ;; `php-ide-status' just messages a summary; simply calling it must not error. + (php-ide-status))) + +(ert-deftest php-ide-test-safe-local-variables () + "`.dir-locals.el' safety predicates must only accept PHP-IDE's own +known feature symbols and bundled executable presets, never an +arbitrary function, path, or command list." + (let ((features-safe (get 'php-ide-features 'safe-local-variable)) + (eglot-exe-safe (get 'php-ide-eglot-executable 'safe-local-variable))) + (should (funcall features-safe '(none))) + (should-not (funcall features-safe '(totally-bogus-feature))) + (should (funcall eglot-exe-safe 'intelephense)) + (should (funcall eglot-exe-safe 'phpactor)) + (should-not (funcall eglot-exe-safe "/bin/ls")) + (should-not (funcall eglot-exe-safe '("curl" "https://example.com/x"))) + ;; `php-ide-mode-functions' can call arbitrary code, so it must have no + ;; safe-local-variable predicate at all and always go through Emacs's + ;; normal risky-variable confirmation. + (should-not (get 'php-ide-mode-functions 'safe-local-variable)))) + +(ert-deftest php-ide-test-eglot-server-programs-registration () + "`php-ide-eglot-activate' should buffer-locally prepend an +`eglot-server-programs' entry only when `php-ide-eglot-executable' is +set, and must never mutate the global value." + (skip-unless (require 'eglot nil t)) + (let ((global-before (copy-sequence eglot-server-programs))) + (unwind-protect + (progn + (with-temp-buffer + (php-mode) + ;; Unconfigured: must not touch `eglot-server-programs' at all. + (cl-letf (((symbol-function 'eglot-ensure) (lambda () nil))) + (php-ide-eglot-activate)) + (should-not (local-variable-p 'eglot-server-programs))) + (with-temp-buffer + (php-mode) + (setq-local php-ide-eglot-executable "psalm-language-server") + (cl-letf (((symbol-function 'eglot-ensure) (lambda () nil))) + (php-ide-eglot-activate)) + (should (equal '("psalm-language-server") + (funcall (cdr (assoc php-ide-eglot-managed-modes + eglot-server-programs))))) + ;; Re-activating must not prepend a duplicate entry. + (let ((len (length eglot-server-programs))) + (cl-letf (((symbol-function 'eglot-ensure) (lambda () nil))) + (php-ide-eglot-activate)) + (should (= len (length eglot-server-programs)))))) + (should (equal global-before eglot-server-programs))))) + ;; For developers: How to make .faces list file. ;; ;; 1. Press `M-x eval-buffer' in this file bufffer. From efd7274c672c3bd6684e6a44f526786af6570c26 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Mon, 3 Aug 2026 21:22:53 +0900 Subject: [PATCH 06/12] Add CHANGELOG entries for the php-ide fixes and improvements --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f21e6043..826e5e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,26 @@ All notable changes of the PHP Mode 1.19.1 release series are documented in this ### Added * `php-project-get-root-dir` falls back to `project-current` when no PHP-specific marker is found, so `project.el` backends (Projectile 3, `project-vc-extra-root-markers`, etc.) can contribute project detection + * Add `php-ide-set-feature` command to interactively enable a PHP-IDE feature for the current buffer + * Only offers features whose backing package is actually available + * Add `php-ide-status` command to report the current PHP-IDE state for the current buffer + * Shows whether it is on, what is configured, and what is available + * Add `php-ide-eglot-activate`, which registers `php-ide-eglot-executable` into `eglot-server-programs` + * Buffer-local, so only buffers that set the variable are affected; previously the variable had no effect on Eglot at all ### Changed * Add `readonly` class modifier to [Imenu] ([#802]) * Add `enum` support to `php-current-class` ([#802]) * Remove hardcoding of implicit paths in `php` that are not guaranteed to exist ([#803]) + * `php-ide-turn-on` no longer errors when `php-ide-features` is unset + * It is now a silent no-op, matching the README's `hack-local-variables-hook` recipe, which previously errored on every PHP file until a feature was configured + * `php-ide-features` now also accepts a bare feature symbol, not just a list + * This was already shown (but not actually supported) in `php-ide.el`'s own Commentary + * Restrict `:safe` on `php-ide-features`/`php-ide-eglot-executable`, and drop `:safe` from `php-ide-mode-functions` entirely + * Previously these let a `.dir-locals.el` silently run an attacker-chosen command or Lisp function without Emacs's usual confirmation; now only PHP-IDE's own known feature symbols and bundled executable presets are accepted + * **If you now get prompted** by a `.dir-locals.el` you wrote and trust yourself (e.g. `php-ide-eglot-executable` set to a raw path/command, or any use of `php-ide-mode-functions`), this is expected — PHP-IDE can no longer vouch for that value as safe. Answer `!` at the prompt to permanently remember that exact value (all supported Emacs versions), or `+` to trust the whole directory from then on (Emacs 30.1+); see `(info "(emacs) Directory Variables")`. You can also pre-approve values ahead of time in your own init file via `safe-local-variable-values` / `safe-local-variable-directories`, so you are never prompted even on first visit. + * `php-ide-mode-functions` was always meant to be configured globally with `add-hook` in your init file (see the Commentary in `php-ide.el`), not set per-project via `.dir-locals.el`. For per-project behavior, branch on the `FEATURE` argument inside your hook function instead of varying the variable's value by directory. ### Fixed @@ -25,6 +39,12 @@ All notable changes of the PHP Mode 1.19.1 release series are documented in this * The first completion reordered the value the user had set, e.g. `(pcntl bcmath core)` became `(bcmath core pcntl)` * Fix function name completion offering module names as if they were PHP functions * Entries of `php-defs-functions-alist` are `(MODULE . FUNCTION-NAMES)` and the whole entry was appended, so every enabled module leaked its own name into the candidates + * Fix the `phpactor` PHP-IDE feature's `:deactivate` to actually deactivate + * It pointed at `php-ide-phpactor-activate` instead of `php-ide-phpactor-deactivate`, so turning `php-ide-mode` off re-activated Phpactor instead + * Fix `php-ide-eglot-server-program` to return a valid command list for symbolic `php-ide-eglot-executable` values + * e.g. `intelephense`, `phpactor` — the `assq` result was used without `cdr` + * Fix the `lsp-mode` PHP-IDE feature's `:deactivate` to no longer error when turning `php-ide-mode` off + * It called `lsp-workspace-shutdown`, which requires a `WORKSPACE` argument and signalled `wrong-number-of-arguments`; switched to the buffer-scoped `lsp-disconnect` ### Deprecated From f4b7f276ce14457a7705c400198daffcc555c27a Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 4 Aug 2026 22:11:42 +0900 Subject: [PATCH 07/12] Make php-ide :safe predicates work before php-ide.el is loaded Emacs decides whether a .dir-locals.el value is safe while hacking local variables, which happens *before* php-ide.el gets loaded: the documented recipe only pulls it in from hack-local-variables-hook, which runs afterwards. The predicates therefore execute as copied into php-mode-autoloads.el, where neither cl-lib nor php-ide.el's own variables are available yet: php-ide-features -> (void-function cl-loop) php-ide-eglot-executable -> (void-variable php-ide-lsp-command-alist) safe-local-variable-p demotes those errors and returns nil, so both variables were treated as unsafe and prompted for confirmation in every project that set them -- exactly the per-project workflow the README documents. Autoload the two alists so the predicates can consult them from the autoloads file, and rewrite the predicates without cl-lib. Also give both alists the docstrings they should have had as public variables. --- lisp/php-ide.el | 39 ++++++++++++++++++++++++++++++++++----- tests/php-mode-test.el | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/lisp/php-ide.el b/lisp/php-ide.el index b57897c5..1c90e57c 100644 --- a/lisp/php-ide.el +++ b/lisp/php-ide.el @@ -106,6 +106,10 @@ (declare-function eglot--managed-mode-off "ext:eglot" ()) (declare-function phpactor--find-executable "ext:phpactor" ())) +;; Autoloaded because the `:safe' predicate of `php-ide-features' consults this +;; alist, and that predicate is copied into the package autoloads file, where it +;; runs while Emacs checks .dir-locals.el — long before php-ide.el itself loads. +;;;###autoload (defvar php-ide-feature-alist '((none :test (lambda () t) :activate (lambda () t) @@ -126,14 +130,32 @@ :deactivate (lambda () (lsp-bridge-mode -1))) (lsp-mode :test (lambda () (and (require 'lsp nil t) (featurep 'lsp))) :activate lsp - :deactivate lsp-disconnect))) + :deactivate lsp-disconnect)) + "Alist of PHP-IDE features and how to probe and (de)activate each one. + +Each element is (FEATURE . PLIST), where PLIST holds these keywords, +each bound to a function called with no arguments: + +`:test' Return non-nil when FEATURE is usable in this Emacs, + loading its backing package if necessary. +`:activate' Turn FEATURE on in the current buffer. +`:deactivate' Turn FEATURE off in the current buffer.") +;; Autoloaded for the same reason as `php-ide-feature-alist'; the `:safe' +;; predicate of `php-ide-eglot-executable' consults this alist. +;;;###autoload (defvar php-ide-lsp-command-alist '((intelephense "intelephense" "--stdio") (phpactor . (lambda () (list (if (fboundp 'phpactor--find-executable) (phpactor--find-executable) "phpactor") - "language-server"))))) + "language-server")))) + "Alist of bundled LSP server presets for `php-ide-eglot-executable'. + +Each element is (NAME . COMMAND), where COMMAND is either a list of +strings to execute or a function of no arguments returning such a list. +Only the NAME symbols listed here are accepted as safe directory-local +values; see `php-ide-eglot-executable'.") (defgroup php-ide nil "IDE-like support for PHP developing." @@ -151,8 +173,15 @@ ;; Only accept feature symbols already known to `php-ide-feature-alist' as safe ;; for .dir-locals.el; an arbitrary symbol here could name a feature added by ;; some future or third-party extension with its own (unvetted) side effects. - :safe (lambda (v) (cl-loop for feature in (if (listp v) v (list v)) - always (assq feature php-ide-feature-alist)))) + ;; + ;; Deliberately written without `cl-lib': this predicate is copied verbatim + ;; into the package autoloads file and runs there while Emacs checks + ;; .dir-locals.el, where cl-lib may not be loaded yet. + :safe (lambda (v) + (let ((features (if (proper-list-p v) v (list v)))) + (not (memq nil (mapcar (lambda (feature) + (and (assq feature php-ide-feature-alist) t)) + features)))))) ;;;###autoload (defcustom php-ide-eglot-executable nil @@ -169,7 +198,7 @@ ;; which `php-ide-eglot-server-program' would later pass straight to ;; `start-process' — that must go through Emacs's normal unsafe-variable ;; confirmation prompt rather than apply silently. - :safe (lambda (v) (assq v php-ide-lsp-command-alist))) + :safe (lambda (v) (and (assq v php-ide-lsp-command-alist) t))) ;;;###autoload (defun php-ide-eglot-server-program () diff --git a/tests/php-mode-test.el b/tests/php-mode-test.el index 4e586d36..ff0da450 100644 --- a/tests/php-mode-test.el +++ b/tests/php-mode-test.el @@ -1058,6 +1058,47 @@ arbitrary function, path, or command list." ;; normal risky-variable confirmation. (should-not (get 'php-ide-mode-functions 'safe-local-variable)))) +(ert-deftest php-ide-test-safe-local-variables-work-from-autoloads () + "Regression test: the `:safe' predicates must work from the package +autoloads file alone. + +Emacs decides whether a .dir-locals.el value is safe *before* php-ide.el +is loaded (the README recipe only pulls php-ide in from +`hack-local-variables-hook', which runs afterwards), so the predicates +run as copied into php-mode-autoloads.el. There they must not depend on +cl-lib nor on variables that only php-ide.el defines, or +`safe-local-variable-p' demotes the resulting error to nil and every +project setting these variables gets a confirmation prompt anyway." + (let ((autoloads (expand-file-name "../lisp/php-mode-autoloads.el" php-mode-test-dir)) + (emacs (expand-file-name invocation-name invocation-directory))) + (skip-unless (file-exists-p autoloads)) + (with-temp-buffer + (let ((status (call-process + emacs nil t nil "-Q" "--batch" + "--load" autoloads + "--eval" + (prin1-to-string + '(progn + ;; Guard against the predicate quietly working only + ;; because php-ide.el got loaded after all. + (when (featurep 'php-ide) + (error "php-ide must not be loaded in this check")) + (dolist (c '((php-ide-features (eglot) t) + (php-ide-features eglot t) + (php-ide-features nil t) + (php-ide-features (bogus-feature) nil) + (php-ide-eglot-executable intelephense t) + (php-ide-eglot-executable phpactor t) + (php-ide-eglot-executable "/bin/ls" nil))) + (let* ((pred (get (nth 0 c) 'safe-local-variable)) + (got (and (funcall pred (nth 1 c)) t))) + (unless (eq got (nth 2 c)) + (error "%s with %S: got %S, want %S" + (nth 0 c) (nth 1 c) got (nth 2 c))))) + (princ "OK")))))) + (should (eq 0 status)) + (should (string-match-p "OK" (buffer-string))))))) + (ert-deftest php-ide-test-eglot-server-programs-registration () "`php-ide-eglot-activate' should buffer-locally prepend an `eglot-server-programs' entry only when `php-ide-eglot-executable' is From 15f73c1c06a97871e9687f4d0dee0c403f5da76d Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 4 Aug 2026 22:12:38 +0900 Subject: [PATCH 08/12] Fix php-ide-phpactor hover suppression to match its documented semantics php-ide-phpactor--disable-hover-at-point-p promises to return non-nil when *any* predicate in php-ide-phpactor-disable-hover-at-point-functions matches, but was written as `never (not ...)', i.e. logical AND. An empty list therefore suppressed hover everywhere -- the opposite of the intent -- and several predicates only fired when all of them matched. The stock one-element value happens to behave the same either way, so this only bit users who customized the variable. Switch to `thereis' and document the variable. --- lisp/php-ide-phpactor.el | 11 ++++++++--- tests/php-mode-test.el | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lisp/php-ide-phpactor.el b/lisp/php-ide-phpactor.el index 52c91f09..fa31f24a 100644 --- a/lisp/php-ide-phpactor.el +++ b/lisp/php-ide-phpactor.el @@ -64,12 +64,17 @@ "Timer object for execute Phpactor and display hover message.") (defvar php-ide-phpactor-disable-hover-at-point-functions - '(php-in-string-or-comment-p)) + '(php-in-string-or-comment-p) + "Predicates that suppress the hover popup at point. + +Each element is called with no arguments; a non-nil return from any of +them means no hover information is requested at point. An empty list +therefore never suppresses hover.") (defun php-ide-phpactor--disable-hover-at-point-p () - "Return non-NIL if any function return non-NIL for disable to hover at point." + "Return non-nil if any function returns non-nil to disable hover at point." (cl-loop for f in php-ide-phpactor-disable-hover-at-point-functions - never (not (funcall f)))) + thereis (funcall f))) (defun php-ide-phpactor-hover () "Show brief information about the symbol underneath the cursor." diff --git a/tests/php-mode-test.el b/tests/php-mode-test.el index ff0da450..6ac4b795 100644 --- a/tests/php-mode-test.el +++ b/tests/php-mode-test.el @@ -1099,6 +1099,28 @@ project setting these variables gets a confirmation prompt anyway." (should (eq 0 status)) (should (string-match-p "OK" (buffer-string))))))) +(ert-deftest php-ide-test-phpactor-disable-hover-at-point-p () + "Regression test: `php-ide-phpactor--disable-hover-at-point-p' must +suppress hover when *any* predicate matches, as its docstring says. + +It used to be written with `never (not ...)', i.e. logical AND, so an +empty list disabled hover everywhere (the exact opposite of the intent) +and a list of several predicates only fired when all of them matched." + (let ((always (lambda () t)) + (never (lambda () nil))) + (let ((php-ide-phpactor-disable-hover-at-point-functions nil)) + (should-not (php-ide-phpactor--disable-hover-at-point-p))) + (let ((php-ide-phpactor-disable-hover-at-point-functions (list always))) + (should (php-ide-phpactor--disable-hover-at-point-p))) + (let ((php-ide-phpactor-disable-hover-at-point-functions (list never))) + (should-not (php-ide-phpactor--disable-hover-at-point-p))) + (let ((php-ide-phpactor-disable-hover-at-point-functions (list always never))) + (should (php-ide-phpactor--disable-hover-at-point-p))) + (let ((php-ide-phpactor-disable-hover-at-point-functions (list never always))) + (should (php-ide-phpactor--disable-hover-at-point-p))) + (let ((php-ide-phpactor-disable-hover-at-point-functions (list never never))) + (should-not (php-ide-phpactor--disable-hover-at-point-p))))) + (ert-deftest php-ide-test-eglot-server-programs-registration () "`php-ide-eglot-activate' should buffer-locally prepend an `eglot-server-programs' entry only when `php-ide-eglot-executable' is From 76c3271cd3889be20e2b2b76d713cf8108b4f6d7 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 4 Aug 2026 22:14:17 +0900 Subject: [PATCH 09/12] Scope the Phpactor hover timer to the buffers that still need it The hover timer is a single global object shared by every buffer, but php-ide-phpactor-deactivate cancelled it unconditionally. Turning php-ide-mode off in one PHP buffer therefore silently stopped hover in every other one, while those buffers still believed it was active. Cancel it only once no live buffer has hover enabled, and let the timer function retire the timer itself, so a buffer killed while active does not leave it running forever. Also clear the buffer-local hover cache on deactivation. --- lisp/php-ide-phpactor.el | 45 +++++++++++++++++++++++++++++++++------- tests/php-mode-test.el | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/lisp/php-ide-phpactor.el b/lisp/php-ide-phpactor.el index fa31f24a..027a7bd4 100644 --- a/lisp/php-ide-phpactor.el +++ b/lisp/php-ide-phpactor.el @@ -61,7 +61,35 @@ (cl-every (lambda (x) (memq x '(all hover navigation))) xs)))) (defvar php-ide-phpactor-timer nil - "Timer object for execute Phpactor and display hover message.") + "Timer object for execute Phpactor and display hover message. + +There is a single timer shared by every buffer using Phpactor hover; it +dispatches to whichever buffer is current when it fires. It therefore +must not be cancelled until the last such buffer is deactivated, which +`php-ide-phpactor--stop-hover-timer' takes care of.") + +(defun php-ide-phpactor--hover-buffer-exists-p () + "Return non-nil if any live buffer still uses Phpactor hover." + (cl-some (lambda (buffer) + (buffer-local-value 'php-ide-phpactor-buffer buffer)) + (buffer-list))) + +(defun php-ide-phpactor--stop-hover-timer () + "Cancel the shared hover timer, but only once no buffer needs it." + (when (and php-ide-phpactor-timer + (not (php-ide-phpactor--hover-buffer-exists-p))) + (cancel-timer php-ide-phpactor-timer) + (setq php-ide-phpactor-timer nil))) + +(defun php-ide-phpactor--hover-timer-function () + "Show hover information, or retire the timer once no buffer needs it. + +Buffers that are killed while active never run +`php-ide-phpactor-deactivate', so the timer also checks here rather than +relying on deactivation alone." + (if (php-ide-phpactor--hover-buffer-exists-p) + (php-ide-phpactor-hover) + (php-ide-phpactor--stop-hover-timer))) (defvar php-ide-phpactor-disable-hover-at-point-functions '(php-in-string-or-comment-p) @@ -109,10 +137,11 @@ therefore never suppresses hover.") (local-set-key [remap xref-find-definitions] #'smart-jump-go) (local-set-key [remap xref-pop-marker-stack] #'smart-jump-back) (local-set-key [remap xref-find-references] #'smart-jump-references))) + (setq php-ide-phpactor-buffer t) (when (php-ide-phpactor--feature-activated-p 'hover) (unless php-ide-phpactor-timer - (setq php-ide-phpactor-timer (run-with-timer 0.8 0.8 #'php-ide-phpactor-hover)))) - (setq php-ide-phpactor-buffer t)) + (setq php-ide-phpactor-timer + (run-with-timer 0.8 0.8 #'php-ide-phpactor--hover-timer-function))))) ;;;###autoload (defun php-ide-phpactor-deactivate () @@ -122,10 +151,12 @@ therefore never suppresses hover.") (local-unset-key [remap xref-pop-marker-stack]) (local-unset-key [remap xref-find-references]) - (when php-ide-phpactor-timer - (cancel-timer php-ide-phpactor-timer) - (setq php-ide-phpactor-timer nil)) - (setq php-ide-phpactor-buffer nil)) + (setq php-ide-phpactor-buffer nil + php-ide-phpactor-hover-last-pos nil + php-ide-phpactor-hover-last-msg nil) + ;; Must run after clearing `php-ide-phpactor-buffer' above, so that this + ;; buffer no longer counts as one that still needs the shared timer. + (php-ide-phpactor--stop-hover-timer)) (provide 'php-ide-phpactor) ;;; php-ide-phpactor.el ends here diff --git a/tests/php-mode-test.el b/tests/php-mode-test.el index 6ac4b795..87cc38fe 100644 --- a/tests/php-mode-test.el +++ b/tests/php-mode-test.el @@ -1121,6 +1121,44 @@ and a list of several predicates only fired when all of them matched." (let ((php-ide-phpactor-disable-hover-at-point-functions (list never never))) (should-not (php-ide-phpactor--disable-hover-at-point-p))))) +(ert-deftest php-ide-test-phpactor-hover-timer-is-shared () + "Regression test: the Phpactor hover timer is shared by every buffer, +so deactivating one buffer must not stop hover in the others. + +It used to be cancelled unconditionally, which silently killed hover in +every remaining PHP buffer. A buffer killed while still active must not +strand the timer either." + (let ((php-ide-phpactor-timer nil) + (buffers nil)) + (unwind-protect + (let ((a (generate-new-buffer " *php-ide-test-a*")) + (b (generate-new-buffer " *php-ide-test-b*"))) + (setq buffers (list a b)) + (with-current-buffer a (php-ide-phpactor-activate)) + (with-current-buffer b (php-ide-phpactor-activate)) + (should php-ide-phpactor-timer) + ;; Deactivating only A must leave the timer running for B. + (with-current-buffer a (php-ide-phpactor-deactivate)) + (should php-ide-phpactor-timer) + (should (buffer-local-value 'php-ide-phpactor-buffer b)) + ;; Once the last buffer goes, the timer must be cancelled. + (with-current-buffer b (php-ide-phpactor-deactivate)) + (should-not php-ide-phpactor-timer) + ;; A buffer killed while active must not strand the timer: the + ;; timer function itself retires it on the next tick. + (let ((c (generate-new-buffer " *php-ide-test-c*"))) + (push c buffers) + (with-current-buffer c (php-ide-phpactor-activate)) + (should php-ide-phpactor-timer) + (kill-buffer c) + (php-ide-phpactor--hover-timer-function) + (should-not php-ide-phpactor-timer))) + (when php-ide-phpactor-timer + (cancel-timer php-ide-phpactor-timer)) + (dolist (buf buffers) + (when (buffer-live-p buf) + (kill-buffer buf)))))) + (ert-deftest php-ide-test-eglot-server-programs-registration () "`php-ide-eglot-activate' should buffer-locally prepend an `eglot-server-programs' entry only when `php-ide-eglot-executable' is From 95760e28c8c07f35a1c2fb527f5ecab8bdc62f2a Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 4 Aug 2026 22:15:12 +0900 Subject: [PATCH 10/12] Document php-ide variables and fix a misleading docstring php-ide-mode-lighter described itself as "A symbol of PHP-IDE feature", which says nothing about the mode line string it actually holds. The remaining undocumented defvars now have docstrings, and php-ide-eglot-managed-modes opens with a complete sentence so checkdoc is clean. --- lisp/php-ide-phpactor.el | 14 +++++++++++--- lisp/php-ide.el | 10 +++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lisp/php-ide-phpactor.el b/lisp/php-ide-phpactor.el index 027a7bd4..a99a3784 100644 --- a/lisp/php-ide-phpactor.el +++ b/lisp/php-ide-phpactor.el @@ -32,9 +32,17 @@ (require 'smart-jump nil t) (require 'cl-lib) -(defvar-local php-ide-phpactor-buffer nil) -(defvar-local php-ide-phpactor-hover-last-pos nil) -(defvar-local php-ide-phpactor-hover-last-msg nil) +(defvar-local php-ide-phpactor-buffer nil + "Non-nil when PHP-IDE has activated Phpactor in this buffer.") + +(defvar-local php-ide-phpactor-hover-last-pos nil + "Buffer position the last hover request was made for.") + +(defvar-local php-ide-phpactor-hover-last-msg nil + "Hover message Phpactor returned for `php-ide-phpactor-hover-last-pos'. + +It is consumed (and cleared) the next time point rests on that same +position, so that the popup only appears once the user has stayed put.") (declare-function phpactor--command-argments "ext:phpactor" (&rest arg-keys)) (declare-function phpactor--parse-json "ext:phpactor" (buffer)) diff --git a/lisp/php-ide.el b/lisp/php-ide.el index 1c90e57c..aa004bd6 100644 --- a/lisp/php-ide.el +++ b/lisp/php-ide.el @@ -212,8 +212,10 @@ values; see `php-ide-eglot-executable'.") ((listp command) command)))))) (defvar php-ide-eglot-managed-modes '(php-mode phps-mode php-ts-mode) - "Major modes for which `php-ide-eglot-activate' overrides -`eglot-server-programs'.") + "Major modes keyed by the `eglot-server-programs' entry php-ide adds. + +`php-ide-eglot-activate' registers `php-ide-eglot-executable' for exactly +these modes.") (defun php-ide-eglot--contact-function (&optional _interactive _project) "CONTACT function registered into `eglot-server-programs' by php-ide. @@ -238,7 +240,9 @@ is unset are unaffected and keep using Eglot's default." (eglot-ensure)) (defcustom php-ide-mode-lighter " PHP-IDE" - "A symbol of PHP-IDE feature." + "Mode line indicator for `php-ide-mode'. + +Set it to an empty string to hide `php-ide-mode' from the mode line." :tag "PHP-IDE Mode Lighter" :type 'string :safe #'stringp) From 2bc9e6eb18a53dccd555ceaab97b5b8a5f92a89a Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 4 Aug 2026 22:15:34 +0900 Subject: [PATCH 11/12] Add CHANGELOG entries for the follow-up php-ide fixes --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 826e5e96..ba450ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,12 @@ All notable changes of the PHP Mode 1.19.1 release series are documented in this * e.g. `intelephense`, `phpactor` — the `assq` result was used without `cdr` * Fix the `lsp-mode` PHP-IDE feature's `:deactivate` to no longer error when turning `php-ide-mode` off * It called `lsp-workspace-shutdown`, which requires a `WORKSPACE` argument and signalled `wrong-number-of-arguments`; switched to the buffer-scoped `lsp-disconnect` + * Fix the `:safe` predicates of `php-ide-features`/`php-ide-eglot-executable`, which never actually applied + * Emacs checks directory-local values *before* `php-ide.el` is loaded, so the predicates ran as copied into the autoloads file, where they hit `void-function cl-loop` / `void-variable php-ide-lsp-command-alist`. `safe-local-variable-p` demotes such errors to nil, so every project setting these variables was prompted for confirmation regardless + * Fix Phpactor hover suppression to match its documented "any predicate matches" behavior + * `php-ide-phpactor-disable-hover-at-point-functions` was combined with AND, so an empty list disabled hover everywhere and multiple predicates only fired when all matched + * Fix `php-ide-mode` deactivation stopping Phpactor hover in unrelated buffers + * The hover timer is shared by all buffers but was cancelled unconditionally; it is now retired only once no live buffer uses hover ### Deprecated From 52585dd5abe8522387ced7b25f738c2caceaac53 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Tue, 4 Aug 2026 23:20:30 +0900 Subject: [PATCH 12/12] Skip the php-ide autoloads :safe check where Emacs drops :safe Emacs 28 was the first to copy a defcustom's :safe predicate into the generated autoloads file, so on Emacs 27 -- still supported -- the subprocess found no predicate to call and the test failed rather than skipping. Mirrors the guard the equivalent php-complete test uses. --- tests/php-mode-test.el | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/php-mode-test.el b/tests/php-mode-test.el index 87cc38fe..824f1896 100644 --- a/tests/php-mode-test.el +++ b/tests/php-mode-test.el @@ -1072,6 +1072,12 @@ project setting these variables gets a confirmation prompt anyway." (let ((autoloads (expand-file-name "../lisp/php-mode-autoloads.el" php-mode-test-dir)) (emacs (expand-file-name invocation-name invocation-directory))) (skip-unless (file-exists-p autoloads)) + ;; Emacs 28 was the first to copy a defcustom's `:safe' predicate into the + ;; generated autoloads file; Emacs 27 drops it, so there is nothing to + ;; check there. + (skip-unless (with-temp-buffer + (insert-file-contents autoloads) + (search-forward "'php-ide-features 'safe-local-variable" nil t))) (with-temp-buffer (let ((status (call-process emacs nil t nil "-Q" "--batch" @@ -1096,8 +1102,10 @@ project setting these variables gets a confirmation prompt anyway." (error "%s with %S: got %S, want %S" (nth 0 c) (nth 1 c) got (nth 2 c))))) (princ "OK")))))) - (should (eq 0 status)) - (should (string-match-p "OK" (buffer-string))))))) + ;; Check the output first: on failure ERT then reports what the + ;; subprocess complained about, not just its exit status. + (should (string-match-p "OK" (buffer-string))) + (should (eq 0 status)))))) (ert-deftest php-ide-test-phpactor-disable-hover-at-point-p () "Regression test: `php-ide-phpactor--disable-hover-at-point-p' must