Skip to content

Make php-ide-mode track what it activated, and protect the alists behind its :safe checks - #827

Merged
zonuexe merged 6 commits into
masterfrom
improve/php-ide-state
Aug 5, 2026
Merged

Make php-ide-mode track what it activated, and protect the alists behind its :safe checks#827
zonuexe merged 6 commits into
masterfrom
improve/php-ide-state

Conversation

@zonuexe

@zonuexe zonuexe commented Aug 5, 2026

Copy link
Copy Markdown
Member

Follow-up to #826, which fixed the features that never worked. This one is about php-ide-mode keeping an accurate account of what it has done, and about closing a gap left in #826's own hardening.

Fixed

php-ide-mode decided what to deactivate by re-reading php-ide-features, so it could not keep the buffer and the mode line in agreement:

  • A feature that failed to activate left the mode on. define-minor-mode sets the mode variable before running the body, and the body signalled without undoing it, so the mode line claimed PHP-IDE was running while nothing had started — and turning it back off then called :deactivate on an unavailable feature, signalling void-function.
  • Editing .dir-locals.el and re-applying it changes php-ide-features in a live buffer, after which deactivation walked the new value: it either signalled on an unknown feature or turned off the wrong one, leaving the feature that was really running behind.
  • Re-enabling the mode activated everything a second time, which happens whenever hack-local-variables-hook runs again, e.g. on revert-buffer.

php-ide-mode now records what it activated, deactivates exactly that, skips what is already active, and rolls back when activation fails partway.

The phpactor feature could not activate outside a package installation. php-ide.el requires php-ide-phpactor only at compile time, so its :activate/:deactivate resolved only because the package autoloads happened to define them; loading php-ide.el on its own left the feature usable by :test but void-function on activation.

Security

#826 restricted the :safe predicates of php-ide-features and php-ide-eglot-executable to "what this package already knows about". That only holds while a project cannot extend what the package knows — and neither backing alist was risky. A .dir-locals.el could add an entry to php-ide-lsp-command-alist and then name it in php-ide-eglot-executable, where our own predicate waved it through as a bundled preset: the project supplied both the command and its own approval. php-ide-feature-alist is the same story for the functions php-ide-mode calls.

Both are now risky, so Emacs always confirms them and never remembers them as safe.

Note that :risky t alone was not enough: unlike :safe, the autoloads generator does not copy it, so it took effect only after php-ide.el was loaded — the same shape of bug as the :safe predicates in #826. The flag is stated with an explicit autoloaded put as well, and a test runs both checks in a subprocess loading only the autoloads file.

Changed

  • php-ide-mode warns when php-ide-features enables more than one LSP client. Activation still proceeds, so a deliberate combination remains possible. Phpactor's bridge is not an LSP client and pairs with one without a warning.
  • php-ide-eglot-deactivate wraps Eglot's internal buffer-scoped switch instead of naming it in the feature table. Eglot exposes no public way to release one buffer without shutting down a server other buffers use, so the internal call stays — but it falls back to eglot--managed-mode and finally to a warning naming eglot-shutdown, rather than failing with void-function if a future Eglot drops it.
  • php-ide-lsp-command-alist and php-ide-eglot-managed-modes are now customizable. Adding your own preset to the former is also how you let a project select that server from .dir-locals.el without confirming an executable path.
  • The "experimental" note now says what it covers. "All of these functions, modes and terms are subject to change without notice" told a user configuring php-ide-features nothing useful; the caveat now points at the php-ide-feature-alist entry keywords and the internals, which really are in flux.

Testing

eask test ert ./tests/php-mode-test.el passes (91 tests, 3 skipped). Each fix above has a regression test, including subprocess checks that the :safe predicates and the risky flags both apply before php-ide.el is loaded. php-ide.el and php-ide-phpactor.el are clean under byte-compile and checkdoc.

Version headers still read 1.26.1 across the tree while 1.27.0 is released. That looks like a separate release-management commit rather than something to fold into a bugfix PR, so it is left alone here.

zonuexe added 6 commits August 5, 2026 20:20
php-ide-mode decided what to deactivate by re-reading php-ide-features,
so it could not keep the buffer and the mode line in agreement:

- A feature that failed to activate left the mode on.  `define-minor-mode'
  sets the mode variable before running the body, and the body signalled
  without undoing it, so the mode line claimed PHP-IDE was running while
  nothing had started -- and turning it back off then called :deactivate
  on an unavailable feature, signalling void-function.
- Editing .dir-locals.el and re-applying it changes php-ide-features in a
  live buffer, after which deactivation walked the new value: it either
  signalled on an unknown feature or turned off the wrong one, leaving
  the feature that was really running behind.
- Re-enabling the mode activated everything a second time, which happens
  whenever hack-local-variables-hook runs again, e.g. on revert-buffer.

Record the features actually activated in a buffer-local variable and
deactivate exactly those, skip ones already active, and roll back (mode
off, features deactivated) when activation fails partway.
php-ide.el requires php-ide-phpactor only at compile time, yet the
phpactor entry of php-ide-feature-alist names php-ide-phpactor-activate
and -deactivate as its :activate/:deactivate.  Those symbols resolved
only because the package autoloads happened to define them; loading
php-ide.el on its own, as a manual installation does, left the feature
usable by :test but void-function on activation.

Require it from :test, next to the phpactor package it already loads.
Restricting the :safe predicates of php-ide-features and
php-ide-eglot-executable to "what this package already knows about" only
holds while a project cannot extend what the package knows.  Neither
backing alist was risky, so a .dir-locals.el could add an entry to
php-ide-lsp-command-alist and then name it in php-ide-eglot-executable,
where our own predicate would wave it through as a bundled preset.
php-ide-feature-alist is the same story for the functions php-ide-mode
calls.

Both are now risky, so Emacs always confirms them and never remembers
them as safe.  The flag is stated with an explicit autoloaded `put' as
well as :risky, because the autoloads generator copies only :safe, and
the check that matters runs before php-ide.el is loaded.

php-ide-lsp-command-alist becomes a defcustom in passing: adding a preset
is how a user makes a server selectable from .dir-locals.el without
being asked to confirm an executable path.
php-ide-features is a set, so nothing stopped two LSP clients from being
enabled together and each trying to manage the same buffer.  Tag the
clients with :exclusive and warn once when more than one of a tag is
requested, while still activating them: an unusual combination stays
possible, it just says so.  Phpactor's bridge is not an LSP client and
pairs with one legitimately, so it is untagged and stays quiet.

Eglot's :deactivate also called eglot--managed-mode-off directly.  It is
internal, and it is still the only way to release one buffer without
shutting down a server other buffers use, so php-ide-eglot-deactivate now
wraps it: it falls back to eglot--managed-mode and finally to a warning
that names eglot-shutdown, rather than failing with void-function if a
future Eglot drops it.

php-ide-eglot-managed-modes becomes a defcustom while nearby.
"All of these functions, modes and terms are subject to change without
notice" told a user configuring php-ide-features nothing useful, and
after this round it is no longer true of the options and commands the
README documents.  Point the caveat at the part that really is in flux:
the keywords a php-ide-feature-alist entry may use, and the internals.
@zonuexe
zonuexe merged commit a3658f6 into master Aug 5, 2026
14 checks passed
@zonuexe
zonuexe deleted the improve/php-ide-state branch August 5, 2026 13:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant