Skip to content

Rewrite php-mode as a CC Mode independent major mode#812

Draft
zonuexe wants to merge 23 commits into
masterfrom
rewrite/cc-free-php-mode
Draft

Rewrite php-mode as a CC Mode independent major mode#812
zonuexe wants to merge 23 commits into
masterfrom
rewrite/cc-free-php-mode

Conversation

@zonuexe

@zonuexe zonuexe commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

This branch replaces the CC Mode based implementation of php-mode with a self-contained one, while keeping the old implementation available:

  • lisp/php-cc-mode.el — the previous CC Mode based mode, renamed to php-cc-mode and frozen for backward compatibility. Both modes can be loaded at the same time, and php-mode-hook users keep working.
  • lisp/php-mode.el — the new mode body: no cc-mode requirement, own font-lock keywords (including PHPDoc highlighting), syntax-propertize based heredoc/nowdoc and PHP 8 attribute handling.
  • lisp/php-indent.el — an indentation engine ported from the syntax-ppss based indenter of js.el in GNU Emacs (GPL attribution in the header), extended with PHP specifics: ->/?->/:: chains, . concatenation alignment, statement continuations (stacked modifiers, broken return, extends on its own line), switch/case, alternative syntax (endif …), and heredoc handling.
  • lisp/php-style.el — a plain variable-alist coding style system replacing c-add-style. The default style is PER (per), psr2/pear/drupal/wordpress remain, symfony2 is removed. A migration layer honors a buffer-local c-basic-offset set via file/dir-local variables and maps it to php-indent-offset with a warning.
  • lisp/php-keywords.el — the PHP keyword vocabulary extracted from the old c-lang-defconst tables, with provenance notes.

Compatibility notes

  • Minimum Emacs is now 28.1 (per the policy discussed in Drop Emacs 27 Support #811).
  • php-mode-lineup-cascaded-callsphp-indent-chain-indent (global user option, not style-bound).
  • c-basic-offsetphp-indent-offset (with the migration layer above).
  • HTML-mixed templates continue to be delegated to Web Mode / polymode; the ?> … <?php HTML region is left untouched by the indenter.

Tests

  • tests/php-mode-test.el was rewritten for the new mode; all previously deferred behaviours (chained-call alignment, operator-anchored continuations, multi-line modifiers, fixup-whitespace integration, pseudo open-tag fontification, and the version-specific faces fixtures) are now implemented and un-deferred. The only remaining :expected-result :failed test is php-mode-test-issue-9, which is skipped on macOS.
  • tests/php-cc-mode-test.el is a new regression suite that keeps the frozen php-cc-mode honest; it runs in CI in a separate Eask process.
  • Version-conditional faces fixtures (.27.faces / .29.faces) were consolidated into plain .faces files now that Emacs ≥ 28.1 is required.

Locally: php-mode-test.el 60/60 as expected (4 environment-dependent skips), php-cc-mode-test.el 16/16, byte-compilation clean.

@zonuexe
zonuexe marked this pull request as draft July 16, 2026 12:14
zonuexe added a commit that referenced this pull request Jul 16, 2026
Extracted from the cc-mode independent php-mode work (#812) so that it
can be reviewed and land independently of the Emacs 28.1 requirement
that blocks that branch until the August release (#811).

The file holds the PHP vocabulary (control structures, declarations and
modifiers, statements, primitive types, constants, magic constants and
magic methods) as plain defconst tables plus a symbol-bounded regexp-opt
for each.  The provenance of every entry is documented in the
commentary: they were derived from the c-lang-defconst tables of the CC
Mode based implementation, supplemented where PHP has gained keywords
since.

This is purely additive: nothing requires php-keywords.el yet, so the
tables have no effect on php-mode until the cc-mode independent mode
lands.
zonuexe added a commit that referenced this pull request Jul 16, 2026
Extracted from the cc-mode independent php-mode work (#812) so that it
can be reviewed and land independently of the Emacs 28.1 requirement
that blocks that branch until the August release (#811).

The engine is ported from the syntax-ppss based indenter of js.el in GNU
Emacs (attributed in the file header); the JavaScript specific handling
(JSX, C preprocessor macros, array comprehensions and regexp literals)
is removed, and PHP specifics are added: -> / ?-> / :: method and
property chains, the . concatenation operator aligned to the assignment,
statement continuations, switch/case, the alternative syntax (endif and
friends) and heredoc/nowdoc bodies.  Its entry points are php-indent-line
and php-indent-region.

The file is self-contained: it only requires php.el for the php
customization group, and defines no symbol that collides with the
current php-mode.

This is purely additive: nothing sets php-indent-line as
indent-line-function yet, so the engine is inert -- the current CC Mode
based php-mode keeps indenting through cc-engine.  Registering both new
files in Eask so they are byte-compiled by CI.
zonuexe added a commit that referenced this pull request Jul 16, 2026
Add php-indent.el and php-keywords.el (extracted from #812)
@zonuexe
zonuexe force-pushed the rewrite/cc-free-php-mode branch 3 times, most recently from c7e2cbf to f6cb997 Compare July 16, 2026 17:44
zonuexe added 19 commits July 17, 2026 03:09
…ation

Move the current CC Mode based implementation aside so the cc-mode
independent rewrite can claim the php-mode name.  The legacy mode is now
php-cc-mode (mode line "PHP/cc") and is in frozen maintenance.

- git mv lisp/php-mode.el lisp/php-cc-mode.el
- Rename the major mode, keymap (php-cc-mode-map), style command
  (php-cc-set-style), syntax table and font-lock keyword lists.
- Retarget the CC Mode language system to php-cc-mode: c-add-language,
  the c-lang-defconst language symbol (php -> php-cc),
  c-init-language-vars and c-common-init.
- Retarget derived-mode-p advice guards to php-cc-mode.
- Drop auto-mode-alist / interpreter-mode-alist registration; these will
  be owned by the new php-mode.
- Run php-mode-hook at the end of php-cc-mode for backward compatibility.
- Point php-mode-debug and Eask at php-cc-mode.
php.el no longer requires cc-mode / cc-engine.

- Replace the c-populate-syntax-table based analysis table with a
  hand-written php--base-syntax-table (shared, to be reused by the new
  php-mode) and derive php--analysis-syntax-table from it.
- Reimplement the token scanner previously borrowed from cc-engine
  (c-end-of-token, c-beginning-of-current-token, c-backward-token-2) as
  self-contained php--end-of-token / php--beginning-of-current-token /
  php--backward-token, using php-re-token-symbols as the token regexp.
  php-leading-tokens output is unchanged.
- Move the shared defcustoms php-mode-coding-style (now defaulting to
  `per', symfony2 removed from the choices), php-mode-template-compatibility
  and php-mode-{pear,drupal,wordpress,psr2}-hook from php-cc-mode.el into
  php.el.  php-cc-mode keeps its Symfony2 hook/style and gains a "per"
  cc-style alias so it still initializes under the new default.
The hand-written PHP base syntax table placed the `b' comment-style
flag on `*' instead of `/' and the newline, so `//' line comments were
classified as `a'-style (block) comments.  `syntax-ppss' element 7 was
therefore nil for `//', causing php-indent--find-newline-backward to
skip past line comments and mis-indent any continuation line whose
predecessor ended with a trailing `// comment'.  Move the flag onto `/'
to mirror the standard C/JavaScript tables.

Also skip a leading block comment in php-indent--continued-expression-p
so that a chained call whose operator is preceded by `/* ... */' (e.g.
`/* c */ ->foo()') is still recognized as a continuation line.
Implement php-mode as a php-base-mode derivative with no dependency on
CC Mode.  Font-lock is built from a single php-font-lock-keywords list
(the cc-independent matchers of the legacy php-cc-font-lock-keywords-3
plus keyword matchers driven by php-keywords.el and a self-contained
php--fontlock-doc-comments PHPDoc matcher).  Indentation delegates to
php-indent.el, coding style to php-style.el.  The heredoc/nowdoc,
attribute and comment-quote syntax-propertize helpers are duplicated
verbatim so php-mode and php-cc-mode can be loaded simultaneously.

Move php-beginning/end-of-defun, imenu, page-delimiter, newcomment and
navigation setup here, and register the auto-mode-alist /
interpreter-mode-alist associations that php-cc-mode.el gave up.  Point
the Eask package-file back at lisp/php-mode.el and list the new files.
Introduce a base "php" style holding the indentation defaults
(php-indent-offset, indent-tabs-mode, tab-width,
php-indent-switch-case-offset, php-indent-chain-indent) and have per,
pear, drupal and wordpress inherit it via php-style-parent.  Previously
pear/drupal/wordpress omitted indent-tabs-mode and tab-width, letting
global defaults leak into the buffer.
While porting the test harness to the cc-mode independent php-mode,
several implementation bugs surfaced.  Fix them at the source:

lisp/php.el (syntax table):
- Mark single-quote (') as a string delimiter; PHP single-quoted
  strings were not fontified or parsed as strings at all.
- Mark `=' as punctuation instead of the Emacs default symbol
  constituent, so `\_<...\_>' token matchers no longer swallow it.

lisp/php-mode.el (font-lock):
- Make the "all upper-cased symbols are constants" matcher
  case-sensitive; font-lock runs with case-fold-search enabled for
  PHP's case-insensitive keywords, which made [A-Z_] match everything.
- Fix php--fontlock-doc-comments to probe syntax-ppss *inside* the
  comment (the opening slash reports being outside it), to apply the
  doc face to the whole comment body, and to ignore a `/**' that
  appears inside another comment.
- Add matchers for class names in type position: type hints written
  before a $-variable, new/instanceof/insteadof/extends/implements,
  namespace and use declarations, enum cases and type casts.
- Fontify self/parent/static as keywords rather than type names.

lisp/php-style.el:
- Reset show-trailing-whitespace / php-style-delete-trailing-whitespace
  in the pear and wordpress styles so switching away from drupal clears
  them, matching the previous behaviour.

tests/*.faces:
- Regenerate errorcontrol and doc-comment/comments fixtures where the
  new output is more correct (`@new' as operator+keyword; a plain
  block comment's closing `*/' is not a delimiter face).
Remove the CC Mode assumptions from the test harness so it exercises
the new php-mode:

- Drop the (c-after-font-lock-init) workaround and the php-mode-debug
  require (php-mode-debug is now specific to php-cc-mode).
- Replace c-guess-basic-syntax with syntax-ppss based checks and debug
  output.
- Replace c-basic-offset with php-indent-offset in assertions, and add
  a dedicated regression test that a legacy buffer-local c-basic-offset
  is honoured via php-style--honor-legacy-c-basic-offset.  The magic
  directive evaluator binds c-basic-offset to php-indent-offset so the
  shared PHP fixtures keep working.
- Drop the removed symfony2 style and switch php-lineup-cascaded-calls
  to php-indent-chain-indent, :style handling to php-enable-*-coding
  -style / php-set-style, and php-cautious-indent-line to
  php-indent-line.
- Split php-mode-test-indentation-issues and php-mode-test-php81 so the
  passing fixtures stay green.

Mark tests that depend on not-yet-ported features (chained-call and
operator continuation alignment, stacked multi-line declarations, the
delete-indentation whitespace fixup, pseudo-tag colouring) as
:expected-result :failed with an explanation, instead of deleting them.
Add tests/php-cc-mode-test.el, a self-contained ert suite that drives
the frozen CC Mode based php-cc-mode through a representative subset of
the indentation and font-lock fixtures (using php-cc-set-style and the
.faces files that still describe CC Mode's output), plus the CC Mode
specific php-mode-debug command.  It is run in its own Eask invocation
so the two major modes, which still share several public symbol names,
do not clobber each other in one Emacs session.
- Bump the minimum Emacs to 28.1 in Eask and in php-cc-mode.el's
  Package-Requires (php-mode.el already declares it).
- Drop Emacs 27.2 from the CI matrix (28.2 is now the minimum tested).
- Add a `test-cc' Makefile target and a CI step that runs the
  php-cc-mode regression suite in a separate Eask invocation.
The legacy `php-cc-mode' and the new `php-mode' are designed to be
loadable in the same Emacs session.  Several public symbols were still
defined by both files, so loading `php-cc-mode' after `php-mode' silently
redefined the new implementation's functions with the CC Mode based ones
(e.g. `php-enable-default-coding-style' was replaced by a `c-set-style'
wrapper, and `php-beginning-of-defun' by the CC Mode navigation variant).

Rename every colliding `php-cc-mode' definition (and all of its in-file
references, keymap remaps, advice, hooks and style-variable keys) under a
`php-cc-'/`php-cc-mode-' prefix:

  php-enable-default-coding-style   -> php-cc-enable-default-coding-style
  php-enable-per-coding-style       -> php-cc-enable-per-coding-style
  php-enable-pear-coding-style      -> php-cc-enable-pear-coding-style
  php-enable-drupal-coding-style    -> php-cc-enable-drupal-coding-style
  php-enable-wordpress-coding-style -> php-cc-enable-wordpress-coding-style
  php-enable-psr2-coding-style      -> php-cc-enable-psr2-coding-style
  php-enable-symfony2-coding-style  -> php-cc-enable-symfony2-coding-style
  php-beginning-of-defun            -> php-cc-beginning-of-defun
  php-end-of-defun                  -> php-cc-end-of-defun
  php-heredoc-start-re              -> php-cc-heredoc-start-re
  php-heredoc-end-re                -> php-cc-heredoc-end-re
  php-syntax-propertize-rules       -> php-cc-syntax-propertize-rules
  php-syntax-propertize-function    -> php-cc-syntax-propertize-function
  php-syntax-propertize-extend-region -> php-cc-syntax-propertize-extend-region
  php--syntax-propertize-heredoc          -> php-cc--syntax-propertize-heredoc
  php--syntax-propertize-quotes-in-comment -> php-cc--syntax-propertize-quotes-in-comment
  php--syntax-propertize-attributes       -> php-cc--syntax-propertize-attributes
  php-string-interpolated-variable-regexp -> php-cc-string-interpolated-variable-regexp
  php-phpdoc-type-names             -> php-cc-phpdoc-type-names
  php-phpdoc-type-tags              -> php-cc-phpdoc-type-tags
  php-phpdoc-font-lock-doc-comments -> php-cc-phpdoc-font-lock-doc-comments
  php-phpdoc-font-lock-keywords     -> php-cc-phpdoc-font-lock-keywords
  php-mode-lineup-cascaded-calls        -> php-cc-mode-lineup-cascaded-calls
  php-mode-enable-backup-style-variables -> php-cc-mode-enable-backup-style-variables
  php-mode--error-control-op-font-lock-find -> php-cc-mode--error-control-op-font-lock-find
  php-mode--string-interpolated-variable-font-lock-find -> php-cc-mode--string-interpolated-variable-font-lock-find
  php-mode--propertize-extend-region-current -> php-cc-mode--propertize-extend-region-current

The public user-facing customization variables that both modes share by
design (`php-mode-hook', `php-mode-page-delimiter', `php-mode-force-pear',
`php-mode-warn-if-html-template', `php-mode-replace-flymake-diag-function',
`php-mode-enable-project-coding-style') and the shared style variable
`php-style-delete-trailing-whitespace' are plain `defcustom's whose second
definition does not override an existing value, so they are intentionally
left shared.  `php-beginning-of-defun-regexp' likewise stays in php.el.
The user-callable `php-enable-*-coding-style' names keep working because
php-style.el defines the same names for the new `php-mode'.

Stop php-mode-debug.el from requiring `php-cc-mode': the require dragged
the whole legacy implementation into any session that autoloaded the
debugger.  Load CC Mode only at compile time, guard the CC Mode specific
diagnostics on `(derived-mode-p 'php-cc-mode)', and let `php-mode-debug'
run in any PHP Mode buffer.
The `:faces t' keyword was placed outside the `with-php-mode-test'
argument list --- `(with-php-mode-test ("issue-136.php") :faces t)' ---
so it was parsed as part of BODY and the macro's `faces' argument stayed
nil.  The test therefore never compared any faces and passed vacuously.

Move `:faces t' inside the argument list so the face assertion runs.  The
committed issue-136.php.faces was stale (it held the golden faces of an
unrelated fixture), so regenerate it from issue-136.php under the same
setup the test uses.  The regenerated data confirms the cc-mode
independent `php-mode' highlights every interpolated-variable syntax
(`$name', `${name}', `{$name}', `{$user->name}', `{$users[$i]->name}',
...) with `php-variable-name', which is exactly what this test guards.
Resolve the indentation behaviours that were deferred when the
cc-mode independent engine was first ported from js.el:

* php-indent--skip-term-backward now skips comments and newlines
  between chain links, so a `->' chain aligns to the first `->' even
  when comment lines are interleaved (issues #115, #135).
* A chain rooted in a `::' static access is no longer aligned to the
  operator; like CC Mode it is treated as a plain statement
  continuation, and inside argument lists it is indented as an
  ordinary element without the continued-expression bonus
  (php-indent--static-chain-in-list-p).
* New php-indent--statement-continuation-indentation indents lines
  that continue an unterminated statement (stacked member modifiers,
  a lone `extends', a broken `return'/`yield', a return-type `):')
  one php-indent-offset beyond the statement's first line.
* New php-indent--concat-continuation-indentation aligns a leading
  `.' string-concatenation line to the `=' of the assignment it
  continues (issue #211).
* When a brace is preceded by a parameter/condition list, the bracket
  indentation now anchors to the start of the whole statement, which
  may lie on an earlier line than the `(' (namespace-block fixture).
* php-indent-chain-indent is no longer set buffer-locally by the
  coding styles; like the old php-mode-lineup-cascaded-calls it is a
  plain global user option (issues #237, #623).
* The open-tag matcher now also matches `<?' followed by a word, so
  pseudo-tags that are not authentic PHP open tags (`<?xml', `<?hh')
  are fontified as tags, matching the accepted false positives of the
  CC Mode implementation (issue #443).
* Add the declare-directive pseudo-keywords `strict_types',
  `encoding' and `ticks' to php-keywords--declarations; they were
  present in php-cc-mode's c-other-kwds but had not been carried over.
* Port the fixup-whitespace advice so that delete-indentation removes
  whitespace around `->'/`::' and before `;'/`,' (issue #73).  The
  function is named php-mode--fixup-whitespace so it does not clash
  with php-cc-mode's php-mode--fixup-whitespace-after.
All ten tests that were marked `:expected-result :failed' when the
cc-mode independent php-mode landed now pass, so drop the markers and
the DEFERRED notes.  Only php-mode-test-issue-9 (skipped on macOS)
remains deferred.

Since the minimum Emacs version is 28.1, the version-conditional
fixture variants are unnecessary:

* issue-443.php.27.faces is removed; the plain .faces file already
  matches the new engine's output (comment ends are fontified with the
  comment face, like the other regenerated fixtures).
* type-hints.php.29.faces is removed; the plain .faces file expects
  the `stdClass' parameter of a method named `object' to get the type
  face, which the new font-lock produces on every version (the
  .29.faces variant encoded a CC Mode regression on Emacs 29+).
* 8.1/readonly.php.faces no longer expects faces on the deliberately
  invalid `claas Err' declaration; the regexp-based font-lock leaves
  malformed code unfontified.
PHP 8.5 adds two constructs that reach the indentation engine:

  $slug = $title
      |> trim(...)
      |> strtolower(...);

  return clone($this, [
      'alpha' => $alpha,
  ]);

The pipe operator starts a continuation line with an operator, and
"clone with" gives `clone' -- until now a bare prefix keyword -- an
argument list.  Both already indent correctly: `|>' matches
php-indent--indent-operator-re, so a line starting with it is a
statement continuation, and `clone(...)' is just a call as far as the
bracket handling is concerned.  Pin that down before it regresses.

The fixtures cover the pipe at the top level, on the same line as the
assignment, inside a function body, inside an argument list, and piping
into static and instance methods; and clone-with inline, with a broken
argument list, and next to a plain `clone $obj'.

Verified the tests have teeth: flattening every line's leading
whitespace and reindenting restores all 35 checked lines.

See https://www.php.net/releases/8.5/en.php
php-pipe-op and its rule arrived on master (#821) in the CC Mode based
php-mode.el, which this branch renames to php-cc-mode.el; the new mode
body needs the same rule for the same reason.  Without it the
comparison-operator matcher claims the `>' of `|>' and leaves the `|'
plain.
Two shapes that put a brace block somewhere the engine does not
otherwise expect one.

PHP 8.5 allows static closures and first-class callables in constant
expressions, so an attribute argument can now hold a closure body:

    #[AccessControl(static function (
        Request $request,
        Post $post,
    ): bool {
        return $request->user === $post->getAuthor();
    })]
    public function update(...)

The `#[' has to stay a token rather than a comment across all of that,
and the declaration after the `)]' has to land back at the class body's
level.  The pre-8.5 spelling, which wraps the same logic in an
Expression object, is covered next to it, along with a first-class
callable, an arrow function, and an attribute holding a nested array.

PHP 8.4 property hooks attach a brace block to a property declaration
rather than to a function signature.  tests/8.4/property-hooks.php
already existed but only checks faces; adding magic comments there would
force its .faces fixture to be regenerated, so the indentation shapes
get a fixture of their own: both short forms, both block forms, the two
hooks on one property, an abstract hook, a typed `set' parameter, and
hooks on a constructor-promoted property.

Both already indent correctly.  Verified the tests have teeth by
flattening every line's leading whitespace and reindenting: all 68
checked lines come back.
@zonuexe
zonuexe force-pushed the rewrite/cc-free-php-mode branch from 34f66d5 to c8c5b04 Compare July 16, 2026 18:13
An arrow function whose body starts on the next line fell back to
column zero:

    $f = fn($x) =>
    $x + 1;

`=>' is deliberately excluded from php-indent--indent-operator-re --- in
PHP it is overwhelmingly the array key operator, and treating every
`'key' => 'value'' as a continuation would be wrong --- so nothing at
all picked up a trailing one, and the body was indented as if it began
a fresh statement.

Property hooks showed the same gap, and worse, an inconsistent one: the
first line after `get =>' stayed put while a following `.' line was
indented by php-indent--statement-continuation-indentation, which does
fire after an identifier:

    get =>
    $this->a
        . ' ';

Treat a trailing `=>' as leaving the statement open, alongside the
identifier / `)' / `):' cases already there.  The three shapes now
agree:

    $f = fn($x) =>
        $x + 1;

    $config = [
        'key' =>
            'a value long enough to wrap',
    ];

    get =>
        $this->a
        . ' ';

Only `=>' is matched, so `>=', `->' and `<=>' keep the handling they
had.  The shape had no fixture at all, which is why the gap went
unnoticed; tests/indent/trailing-arrow.php now covers arrow functions,
array values, the bracket-on-the-same-line case, property hooks, match
arms, and a `>=' left at the end of a line.
zonuexe and others added 3 commits July 18, 2026 04:56
Redefine php-complete.el as a small collection of dependency-light,
offline completion-at-point functions usable both as `M-x` commands and
as building blocks for `cape-capf-super'.

* lisp/php.el (php-dot-context): New dependency-free primitive that
  classifies the context before point (`string-or-comment',
  `next-to-string', `code') for context-sensitive `.' insertion, without
  cc-engine.  Meant to drive smartchr/key-combo setups.

* lisp/php-complete.el: State the file's mission in the Commentary.
  (php-complete-complete-function): Redefine as the offline built-in
  function-name source (behavior unchanged).
  (php-complete-path-dir-constants): New option; directory-valued subset
  of `php-magical-constants'.
  (php-complete-complete-path): New capf completing a filesystem path in
  the `__DIR__ . '/...'' idiom, one component at a time, rooted at the
  directory of the current file.  Robust to the unterminated string that
  is normal while typing.

* tests/php-mode-test.el: Cover `php-dot-context' and
  `php-complete-complete-path'.

* README.md, README.ja.md: Document the completion capfs, `cape-capf-super'
  composition, and a `php-dot-context'-based smartchr recipe for `.'.
Follow the "Personal Settings" pattern in the README: add the capfs from
a named setup function inside `with-eval-after-load 'php-mode', rather
than adding an anonymous closure to the hook, which is hard to remove or
redefine later.

Also fix "補完" that had been garbled to "補completion" in README.ja.md.
Add `__DIR__` path completion capf and `php-dot-context` primitive
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