branch: elpa/php-mode
commit 22e5c803efed18442a9aec7b728c1f6c29757e7c
Merge: 52292445b5 45bb2bff79
Author: USAMI Kenta <[email protected]>
Commit: GitHub <[email protected]>

    Merge pull request #814 from emacs-php/feat/php-indent-keywords
    
    Add php-indent.el and php-keywords.el (extracted from #812)
---
 Eask                 |   2 +
 lisp/php-indent.el   | 711 +++++++++++++++++++++++++++++++++++++++++++++++++++
 lisp/php-keywords.el | 246 ++++++++++++++++++
 3 files changed, 959 insertions(+)

diff --git a/Eask b/Eask
index 6fe9bb805e..1ec578047d 100644
--- a/Eask
+++ b/Eask
@@ -12,6 +12,8 @@
  "lisp/php.el"
  "lisp/php-complete.el"
  "lisp/php-defs.el"
+ "lisp/php-indent.el"
+ "lisp/php-keywords.el"
  "lisp/php-face.el"
  "lisp/php-format.el"
  "lisp/php-project.el"
diff --git a/lisp/php-indent.el b/lisp/php-indent.el
new file mode 100644
index 0000000000..00dd541986
--- /dev/null
+++ b/lisp/php-indent.el
@@ -0,0 +1,711 @@
+;;; php-indent.el --- Indentation engine for PHP Mode  -*- lexical-binding: t; 
-*-
+
+;; Copyright (C) 2026 Friends of Emacs-PHP development
+;; Copyright (C) 1999-2026 Free Software Foundation, Inc.
+
+;; Author: USAMI Kenta <[email protected]>
+;; Keywords: languages, php
+;; Homepage: https://github.com/emacs-php/php-mode
+;; License: GPL-3.0-or-later
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file implements the indentation engine used by PHP Mode.
+;;
+;; The engine is ported from the `syntax-ppss'-based indenter of js.el
+;; in GNU Emacs (originally derived from js.el in GNU Emacs).  It does
+;; NOT depend on CC Mode.  The JavaScript-specific handling of js.el
+;; (JSX, C preprocessor macros, array comprehensions and regular
+;; expression literals) has been removed, and PHP-specific behaviour has
+;; been added: `->', `?->' and `::' method/property chains, the `.'
+;; string concatenation operator, PHP `switch'/`case' indentation, a
+;; simplified alternative-syntax (`endif' etc.) alignment, and leaving
+;; heredoc/nowdoc and string bodies untouched.
+;;
+;; The public entry points are `php-indent-line' (suitable as
+;; `indent-line-function') and `php-indent-region'.
+
+;;; Code:
+
+(require 'php)
+
+;;; Customization
+
+(defcustom php-indent-offset 4
+  "Number of columns for one level of PHP indentation."
+  :group 'php
+  :tag "PHP Indent Offset"
+  :type 'integer
+  :safe #'integerp)
+
+(defcustom php-indent-switch-case-offset 4
+  "Indentation of `case'/`default' labels relative to their `switch'.
+Statements inside a `case' are indented by a further `php-indent-offset'."
+  :group 'php
+  :tag "PHP Indent Switch Case Offset"
+  :type 'integer
+  :safe #'integerp)
+
+(defcustom php-indent-chain-indent t
+  "If non-nil, align successive method/property chain lines.
+A chained line begins with `->', `?->' or `::'."
+  :group 'php
+  :tag "PHP Indent Chain Indent"
+  :type 'boolean
+  :safe #'booleanp)
+
+(defcustom php-indent-paren-offset 0
+  "Additional indentation for lines inside a `(...)' group."
+  :group 'php
+  :tag "PHP Indent Paren Offset"
+  :type 'integer
+  :safe #'integerp)
+
+(defcustom php-indent-square-offset 0
+  "Additional indentation for lines inside a `[...]' group."
+  :group 'php
+  :tag "PHP Indent Square Offset"
+  :type 'integer
+  :safe #'integerp)
+
+(defcustom php-indent-curly-offset 0
+  "Additional indentation for lines inside a `{...}' group."
+  :group 'php
+  :tag "PHP Indent Curly Offset"
+  :type 'integer
+  :safe #'integerp)
+
+(defcustom php-indent-switch-offset 0
+  "Additional indentation applied to the whole body of a `switch'."
+  :group 'php
+  :tag "PHP Indent Switch Offset"
+  :type 'integer
+  :safe #'integerp)
+
+;;; Constants
+
+(defconst php-indent--name-start-re "[[:alpha:]_$]"
+  "Regexp matching the start of a PHP identifier, without grouping.")
+
+(defconst php-indent--name-re
+  (concat php-indent--name-start-re "\\(?:\\s_\\|\\sw\\)*")
+  "Regexp matching a PHP identifier, without grouping.")
+
+(defun php-indent--regexp-opt-symbol (list)
+  "Like `regexp-opt', but surround the result with symbol boundaries.
+LIST is a list of strings to match as whole symbols."
+  (concat "\\_<" (regexp-opt list t) "\\_>"))
+
+(defconst php-indent--possibly-braceless-keyword-re
+  (php-indent--regexp-opt-symbol
+   '("if" "else" "elseif" "for" "foreach" "while" "do" "switch" "declare"))
+  "Regexp matching keywords optionally followed by an opening brace.")
+
+(defconst php-indent--declaration-keyword-re
+  (regexp-opt '("const") 'words)
+  "Regexp matching declaration keywords for multi-line alignment.")
+
+(defconst php-indent--indent-operator-re
+  (concat "[-+*/%<>&^|?:.]\\([^-+*/.]\\|$\\)\\|!?=\\([^>]\\|$\\)\\|"
+          (php-indent--regexp-opt-symbol '("instanceof")))
+  "Regexp matching operators that affect indentation of continued lines.
+Note that `=>' (the array/arrow key operator) is intentionally excluded.")
+
+(defconst php-indent--line-terminating-arrow-re "=>\\s-*\\(/[/*]\\|$\\)"
+  "Regexp matching a `=>' token that terminates a line.
+Whitespace and comments around the arrow are ignored.")
+
+(defconst php-indent--altsyntax-re
+  
"\\(end\\(?:if\\|foreach\\|while\\|for\\|switch\\|declare\\)\\|else\\(?:if\\)?\\)\\_>"
+  "Regexp matching alternative-syntax closing/continuation keywords.")
+
+;;; Low-level scanning helpers (ported from js.el)
+
+(defun php-indent--re-search-forward-inner (regexp &optional bound count)
+  "Helper for `php-indent--re-search-forward'.
+Search for REGEXP up to BOUND, skipping COUNT matches inside strings,
+comments and heredocs."
+  (let (parse str-terminator)
+    (while (> count 0)
+      (re-search-forward regexp bound)
+      (setq parse (syntax-ppss))
+      (cond ((setq str-terminator (nth 3 parse))
+             (when (eq str-terminator t)
+               (setq str-terminator ?/))
+             (re-search-forward
+              (concat "\\([^\\]\\|^\\)" (string str-terminator))
+              (line-end-position) t))
+            ((nth 7 parse)
+             (forward-line))
+            ((or (nth 4 parse)
+                 (and (eq (char-before) ?/) (eq (char-after) ?*)))
+             (re-search-forward "\\*/"))
+            (t
+             (setq count (1- count))))))
+  (point))
+
+(defun php-indent--re-search-forward (regexp &optional bound noerror count)
+  "Search forward, ignoring strings and comments.
+This invokes `re-search-forward' but treats the buffer as if strings and
+comments have been removed.  REGEXP, BOUND, NOERROR and COUNT are as for
+`re-search-forward'."
+  (unless count (setq count 1))
+  (let ((saved-point (point))
+        (search-fun
+         (cond ((< count 0) (setq count (- count))
+                #'php-indent--re-search-backward-inner)
+               ((> count 0) #'php-indent--re-search-forward-inner)
+               (t #'ignore))))
+    (condition-case err
+        (funcall search-fun regexp bound count)
+      (search-failed
+       (goto-char saved-point)
+       (unless noerror
+         (signal (car err) (cdr err)))))))
+
+(defun php-indent--re-search-backward-inner (regexp &optional bound count)
+  "Helper for `php-indent--re-search-backward'.
+Search backward for REGEXP up to BOUND, skipping COUNT matches inside
+strings and comments."
+  (let (parse)
+    (while (> count 0)
+      (re-search-backward regexp bound)
+      (when (and (> (point) (point-min))
+                 (save-excursion (backward-char) (looking-at "/[/*]")))
+        (forward-char))
+      (setq parse (syntax-ppss))
+      (cond ((nth 8 parse)
+             (goto-char (nth 8 parse)))
+            ((or (nth 4 parse)
+                 (and (eq (char-before) ?/) (eq (char-after) ?*)))
+             (re-search-backward "/\\*"))
+            (t
+             (setq count (1- count))))))
+  (point))
+
+(defun php-indent--re-search-backward (regexp &optional bound noerror count)
+  "Search backward, ignoring strings and comments.
+REGEXP, BOUND, NOERROR and COUNT are as for `re-search-backward'."
+  (php-indent--re-search-forward regexp bound noerror (if count (- count) -1)))
+
+(defun php-indent--backward-syntactic-ws (&optional lim)
+  "Move backward over whitespace and comments, not before LIM."
+  (save-restriction
+    (when lim (narrow-to-region lim (point-max)))
+    (let ((pos (point)))
+      (while (progn
+               (forward-comment most-negative-fixnum)
+               (/= (point) (prog1 pos (setq pos (point)))))))))
+
+(defun php-indent--forward-syntactic-ws (&optional lim)
+  "Move forward over whitespace and comments, not past LIM."
+  (save-restriction
+    (when lim (narrow-to-region (point-min) lim))
+    (let ((pos (point)))
+      (while (progn
+               (forward-comment most-positive-fixnum)
+               (/= (point) (prog1 pos (setq pos (point)))))))))
+
+(defun php-indent--same-line (pos)
+  "Return non-nil if POS is on the current line."
+  (and (>= pos (line-beginning-position))
+       (<= pos (line-end-position))))
+
+(defun php-indent--find-newline-backward ()
+  "Move backward to the nearest newline that is not in a block comment.
+Return non-nil on success."
+  (let ((continue t)
+        (result t))
+    (while continue
+      (setq continue nil)
+      (if (search-backward "\n" nil t)
+          (let ((parse (syntax-ppss)))
+            (when (nth 4 parse)
+              (goto-char (nth 8 parse))
+              (unless (nth 7 parse)
+                (setq continue t))))
+        (setq result nil)))
+    result))
+
+;;; Operator / expression predicates
+
+(defun php-indent--looking-at-operator-p ()
+  "Return non-nil if point is on a PHP operator, other than a comma."
+  (save-match-data
+    (and (looking-at php-indent--indent-operator-re)
+         (or (not (eq (char-after) ?:))
+             (save-excursion
+               (php-indent--backward-syntactic-ws)
+               (when (eq (char-before) ?\)) (backward-list))
+               (and (php-indent--re-search-backward "[?:{]\\|\\_<case\\_>" nil 
t)
+                    (eq (char-after) ??)))))))
+
+(defun php-indent--continued-expression-p ()
+  "Return non-nil if the current line continues an expression."
+  (save-excursion
+    (back-to-indentation)
+    ;; Skip a leading block comment so that a chain line whose operator is
+    ;; preceded by `/* ... */' is still recognized (e.g. `/* c */ ->foo()').
+    (while (looking-at "/\\*")
+      (forward-comment 1)
+      (skip-syntax-forward " "))
+    (cond
+     ;; Lines starting with a chain operator continue the previous line.
+     ((looking-at "->\\|\\?->\\|::") t)
+     ((php-indent--looking-at-operator-p)
+      (or
+       (not (memq (char-after) '(?- ?+)))
+       (progn
+         (forward-comment (- (point)))
+         (not (memq (char-before) '(?, ?\[ ?\())))))
+     (t
+      (and (php-indent--find-newline-backward)
+           (progn
+             (skip-chars-backward " \t")
+             (progn
+               (or (bobp) (backward-char))
+               (and (> (point) (point-min))
+                    (save-excursion
+                      (backward-char)
+                      (not (looking-at "[/*]/\\|=>")))
+                    (php-indent--looking-at-operator-p)
+                    (progn (backward-char)
+                           (not (looking-at "\\+\\+\\|--\\|/[/*]")))))))))))
+
+(defun php-indent--skip-term-backward ()
+  "Skip a term before point.
+Return non-nil if the skipped term is preceded by a `->', `?->' or `::'
+chain operator, leaving point at the start of that operator.  Whitespace
+and comments (including whole comment lines between chain links) are
+skipped."
+  (let ((term-skipped nil))
+    ;; Skip backward over balanced parens.
+    (let ((progress t))
+      (while progress
+        (setq progress nil)
+        (php-indent--backward-syntactic-ws)
+        (when (memq (char-before) '(?\] ?\) ?\}))
+          (setq progress t term-skipped t)
+          (backward-list))))
+    ;; Maybe skip over a symbol.
+    (let ((save-point (point)))
+      (if (and (< (skip-syntax-backward "w_") 0)
+               (looking-at php-indent--name-re))
+          (setq term-skipped t)
+        (goto-char save-point)))
+    (when (and term-skipped (> (point) (point-min)))
+      (php-indent--backward-syntactic-ws)
+      (let ((end (point)))
+        (cond
+         ((and (>= (- end 3) (point-min))
+               (save-excursion (goto-char (- end 3)) (looking-at "\\?->")))
+          (goto-char (- end 3)) t)
+         ((and (>= (- end 2) (point-min))
+               (save-excursion (goto-char (- end 2)) (looking-at "->\\|::")))
+          (goto-char (- end 2)) t)
+         (t nil))))))
+
+(defun php-indent--skip-terms-backward ()
+  "Skip any number of chained terms backward.
+Move point to the earliest chain operator without changing paren levels.
+Return non-nil if at least one term was skipped."
+  (when (php-indent--skip-term-backward)
+    (let ((last-point (point)))
+      (while (php-indent--skip-term-backward)
+        (setq last-point (point)))
+      (goto-char last-point)
+      t)))
+
+(defun php-indent--chained-expression-p ()
+  "Return the indentation column for a method/property chain line.
+Return nil when the current line is not a continued chain line, or when
+the chain is rooted in a `::' static access (CC Mode indented those as
+plain statement continuations rather than aligning to the operator).
+Point is expected to be at `back-to-indentation'."
+  (when php-indent-chain-indent
+    (save-excursion
+      (when (and (looking-at "->\\|\\?->\\|::")
+                 (php-indent--continued-expression-p))
+        (php-indent--backward-syntactic-ws)
+        (when (and (php-indent--skip-terms-backward)
+                   (not (looking-at "::")))
+          (current-column))))))
+
+(defun php-indent--static-chain-in-list-p (parse-status)
+  "Return non-nil for a `::'-rooted chain line inside a paren/bracket group.
+PARSE-STATUS is the result of `syntax-ppss'.  CC Mode did not recognize
+these as statement continuations inside argument lists, so they are
+indented as plain list elements without the continued-expression bonus."
+  (let ((open (nth 1 parse-status)))
+    (and php-indent-chain-indent
+         open
+         (memq (char-after open) '(?\( ?\[))
+         (save-excursion
+           (back-to-indentation)
+           (when (looking-at "->\\|\\?->\\|::")
+             (php-indent--backward-syntactic-ws)
+             (and (php-indent--skip-terms-backward)
+                  (looking-at "::")))))))
+
+(defun php-indent--end-of-do-while-loop-p ()
+  "Return non-nil if point is on the \"while\" of a do-while statement."
+  (save-excursion
+    (save-match-data
+      (when (looking-at "\\s-*\\_<while\\_>")
+        (if (save-excursion
+              (skip-chars-backward " \t\n}")
+              (looking-at "[ \t\n]*}"))
+            (save-excursion
+              (backward-list) (forward-symbol -1) (looking-at "\\_<do\\_>"))
+          (php-indent--re-search-backward "\\_<do\\_>" 
(line-beginning-position) t)
+          (or (looking-at "\\_<do\\_>")
+              (let ((saved-indent (current-indentation)))
+                (while (and (php-indent--re-search-backward "^\\s-*\\_<" nil t)
+                            (/= (current-indentation) saved-indent)))
+                (and (looking-at "\\s-*\\_<do\\_>")
+                     (not (php-indent--re-search-forward
+                           "\\_<while\\_>" (line-end-position) t))
+                     (= (current-indentation) saved-indent)))))))))
+
+(defun php-indent--ctrl-statement-indentation ()
+  "Return the indentation for a braceless control-statement body, or nil."
+  (save-excursion
+    (back-to-indentation)
+    (when (save-excursion
+            (and (not (eq (line-beginning-position) (point-min)))
+                 (not (looking-at "[{]"))
+                 (php-indent--re-search-backward "[[:graph:]]" nil t)
+                 (progn
+                   (or (eobp) (forward-char))
+                   (when (= (char-before) ?\)) (backward-list))
+                   (skip-syntax-backward " ")
+                   (skip-syntax-backward "w_")
+                   (looking-at php-indent--possibly-braceless-keyword-re))
+                 (memq (char-before) '(?\s ?\t ?\n ?\}))
+                 (not (php-indent--end-of-do-while-loop-p))))
+      (save-excursion
+        (goto-char (match-beginning 0))
+        (+ (current-indentation) php-indent-offset)))))
+
+(defun php-indent--multi-line-declaration-indentation ()
+  "Return the indentation for a continued `const' declaration, or nil."
+  (let (forward-sexp-function ; Use Lisp version.
+        at-opening-bracket)
+    (save-excursion
+      (back-to-indentation)
+      (when (not (looking-at php-indent--declaration-keyword-re))
+        (when (looking-at php-indent--indent-operator-re)
+          (goto-char (match-end 0)))
+        (while (and (not at-opening-bracket)
+                    (not (bobp))
+                    (let ((pos (point)))
+                      (save-excursion
+                        (php-indent--backward-syntactic-ws)
+                        (or (eq (char-before) ?,)
+                            (and (not (eq (char-before) ?\;))
+                                 (prog2
+                                     (skip-syntax-backward ".")
+                                     (looking-at 
php-indent--indent-operator-re)
+                                   (php-indent--backward-syntactic-ws))
+                                 (not (eq (char-before) ?\;)))
+                            (php-indent--same-line pos)))))
+          (condition-case nil
+              (backward-sexp)
+            (scan-error (setq at-opening-bracket t))))
+        (when (looking-at php-indent--declaration-keyword-re)
+          (goto-char (match-end 0))
+          (1+ (current-column)))))))
+
+(defun php-indent--open-tag-before-p ()
+  "Return non-nil when point is immediately after a PHP open tag."
+  (looking-back "<\\?\\(?:php\\|=\\)?" (max (point-min) (- (point) 5))))
+
+(defun php-indent--case-label-colon-p ()
+  "Return non-nil when the `:' before point ends a case/default label."
+  (save-excursion
+    (beginning-of-line)
+    (looking-at "[ \t]*\\(?:case\\_>\\|default[ \t]*:\\)")))
+
+(defun php-indent--goto-statement-start ()
+  "Move point to the first token of the statement around point.
+The statement start is found by scanning balanced expressions backward
+until a statement terminator (`;', `{', `}', an opening `(' or `[', a
+`,', a case label `:' or a PHP open tag) is reached."
+  (let (forward-sexp-function done)
+    (while (not done)
+      (let ((last (point)))
+        (php-indent--backward-syntactic-ws)
+        (if (or (bobp)
+                (memq (char-before) '(?\; ?\{ ?\} ?\( ?\[ ?,))
+                (php-indent--open-tag-before-p)
+                (and (eq (char-before) ?:)
+                     (php-indent--case-label-colon-p)))
+            (progn (goto-char last) (setq done t))
+          (skip-syntax-backward ".")
+          (condition-case nil
+              (backward-sexp)
+            (scan-error (goto-char last) (setq done t))))))))
+
+(defun php-indent--statement-continuation-indentation ()
+  "Return the indentation for a continuation line of a broken statement.
+A continuation line is one whose previous code token leaves the
+statement unterminated: an identifier or keyword (as in stacked member
+modifiers, `extends' on its own line or a broken `return'), a closing
+parenthesis, or a return-type `):'.  Such lines are indented one
+`php-indent-offset' beyond the first line of the statement.  Return nil
+when the current line does not continue a statement this way."
+  (save-excursion
+    (back-to-indentation)
+    (unless (or (memq (char-after) '(?\{ ?\} ?\) ?\]))
+                (looking-at "/[/*]\\|#"))
+      (let ((pos (point)))
+        (php-indent--backward-syntactic-ws)
+        (when (and (not (bobp))
+                   (not (php-indent--open-tag-before-p))
+                   (or (memq (char-syntax (char-before)) '(?w ?_))
+                       (eq (char-before) ?\))
+                       ;; A `):' return-type (or alternative-syntax `):')
+                       ;; also leaves the statement open.
+                       (and (eq (char-before) ?:)
+                            (eq (char-before (1- (point))) ?\)))))
+          (goto-char pos)
+          (php-indent--goto-statement-start)
+          (unless (php-indent--same-line pos)
+            (+ (current-indentation) php-indent-offset)))))))
+
+(defun php-indent--concat-continuation-indentation ()
+  "Return the alignment column for a leading-`.' concatenation line.
+CC Mode aligned a continuation line beginning with the `.' string
+concatenation operator to the `=' of the assignment it continues.
+Return nil when the line does not start with `.' or the statement's
+first line contains no plain assignment operator."
+  (save-excursion
+    (back-to-indentation)
+    (when (looking-at "\\.[^.=0-9]")
+      (php-indent--goto-statement-start)
+      (let ((eol (line-end-position)) col)
+        (while (and (not col) (re-search-forward "=" eol t))
+          (let ((pos (match-beginning 0)))
+            (when (and (not (nth 8 (save-excursion (syntax-ppss pos))))
+                       (not (memq (char-before pos)
+                                  '(?= ?! ?< ?> ?+ ?- ?* ?/ ?. ?% ?& ?| ?^ 
??)))
+                       (not (memq (char-after (1+ pos)) '(?= ?>))))
+              (goto-char pos)
+              (setq col (current-column)))))
+        col))))
+
+(defun php-indent--broken-arrow-terminates-line-p ()
+  "Return non-nil if the current line's last real token is a `=>' arrow."
+  (let ((from (point)))
+    (end-of-line)
+    (re-search-backward php-indent--line-terminating-arrow-re from t)))
+
+;;; switch/case and alternative syntax
+
+(defun php-indent--switch-block-p (open)
+  "Return non-nil if OPEN is the opening brace of a `switch' block."
+  (and (eq (char-after open) ?\{)
+       (save-excursion
+         (goto-char open)
+         (skip-syntax-backward " ")
+         (when (eq (char-before) ?\)) (backward-list))
+         (skip-syntax-backward " ")
+         (skip-syntax-backward "w_")
+         (looking-at "\\_<switch\\_>"))))
+
+(defun php-indent--switch-line-indent (open)
+  "Return the indentation of the `switch' line whose brace is at OPEN."
+  (save-excursion
+    (goto-char open)
+    (skip-syntax-backward " ")
+    (when (eq (char-before) ?\)) (backward-list))
+    (back-to-indentation)
+    (current-column)))
+
+(defun php-indent--altsyntax-indentation ()
+  "Return the indentation for an alternative-syntax line, or nil.
+Handles `endif', `endforeach', `endwhile', `endfor', `endswitch',
+`enddeclare' and the colon forms of `else'/`elseif'."
+  (save-excursion
+    (back-to-indentation)
+    (when (and (looking-at php-indent--altsyntax-re)
+               (let ((kw (match-string-no-properties 1)))
+                 (or (string-prefix-p "end" kw)
+                     ;; else:/elseif (...): must end the line with a colon.
+                     (save-excursion
+                       (end-of-line)
+                       (skip-syntax-backward " ")
+                       (eq (char-before) ?:)))))
+      (let* ((kw (match-string-no-properties 1))
+             (open-re (cond
+                       ((member kw '("else" "elseif" "endif")) "\\_<if\\_>")
+                       ((string= kw "endforeach") "\\_<foreach\\_>")
+                       ((string= kw "endwhile") "\\_<while\\_>")
+                       ((string= kw "endfor") "\\_<for\\_>")
+                       ((string= kw "endswitch") "\\_<switch\\_>")
+                       ((string= kw "enddeclare") "\\_<declare\\_>"))))
+        (when (and open-re
+                   (php-indent--re-search-backward open-re nil t))
+          (current-indentation))))))
+
+;;; Core indentation
+
+(defun php-indent--proper-indentation (parse-status)
+  "Return the proper indentation for the current line.
+PARSE-STATUS is the result of `syntax-ppss' at the line beginning."
+  (save-excursion
+    (back-to-indentation)
+    (cond ((nth 4 parse-status)      ; inside comment
+           (php-indent--comment-indentation parse-status))
+          ((nth 3 parse-status) 0)   ; inside string/heredoc (kept by line fn)
+          ;; PHP open/close tags anchor to column zero.
+          ((looking-at "<\\?\\|\\?>") (prog-first-column))
+          ((php-indent--altsyntax-indentation))
+          ((php-indent--chained-expression-p))
+          ((php-indent--ctrl-statement-indentation))
+          ((php-indent--multi-line-declaration-indentation))
+          ((php-indent--concat-continuation-indentation))
+          ((and (not (php-indent--static-chain-in-list-p parse-status))
+                (php-indent--statement-continuation-indentation)))
+          ((nth 1 parse-status)
+           (php-indent--bracket-indentation parse-status))
+          ((php-indent--continued-expression-p)
+           php-indent-offset)
+          (t (prog-first-column)))))
+
+(defun php-indent--comment-indentation (parse-status)
+  "Return the indentation of a line inside a block comment.
+PARSE-STATUS is the result of `syntax-ppss'.  Continuation lines of a
+`/* ... */' comment are aligned one column past the star."
+  (let ((start (nth 8 parse-status)))
+    (save-excursion
+      (goto-char start)
+      (if (looking-at "/\\*")
+          (1+ (current-column))
+        (current-column)))))
+
+(defun php-indent--bracket-indentation (parse-status)
+  "Return the indentation for a line inside a bracketed group.
+PARSE-STATUS is the result of `syntax-ppss'; point is at the line's
+indentation."
+  (let ((open (nth 1 parse-status))
+        (same-indent-p (looking-at "[]})]"))
+        (switch-label-p (looking-at "\\(?:case\\_>\\|default\\_>\\)"))
+        (continued-expr-p (and (not (php-indent--static-chain-in-list-p 
parse-status))
+                               (php-indent--continued-expression-p))))
+    (if (php-indent--switch-block-p open)
+        ;; PHP switch/case specific handling.
+        (let ((switch-indent (php-indent--switch-line-indent open)))
+          (cond
+           (same-indent-p (+ switch-indent php-indent-switch-offset))
+           (switch-label-p (+ switch-indent
+                              php-indent-switch-case-offset
+                              php-indent-switch-offset))
+           (t (+ switch-indent
+                 php-indent-switch-case-offset
+                 php-indent-offset
+                 php-indent-switch-offset))))
+      ;; Generic bracket handling (ported from js.el).
+      (goto-char open)
+      (if (or (looking-at "[({[]\\s-*\\(/[/*]\\|$\\)")
+              (save-excursion (forward-char)
+                              (php-indent--broken-arrow-terminates-line-p)))
+          (progn ; nothing following the opening paren/bracket
+            (skip-syntax-backward " ")
+            (when (eq (char-before) ?\))
+              ;; The brace is preceded by a parameter/condition list; the
+              ;; anchor is the start of the whole statement (which may be
+              ;; on an earlier line than the `(' when the parameter list
+              ;; is broken onto its own line).
+              (backward-list)
+              (php-indent--goto-statement-start))
+            (back-to-indentation)
+            (+ (current-column)
+               (cond (same-indent-p 0)
+                     (continued-expr-p (* 2 php-indent-offset))
+                     (t (+ php-indent-offset
+                           (pcase (char-after open)
+                             (?\( php-indent-paren-offset)
+                             (?\[ php-indent-square-offset)
+                             (?\{ php-indent-curly-offset)
+                             (_ 0)))))))
+        ;; Something follows the opening bracket: align to it.
+        (unless same-indent-p
+          (forward-char)
+          (skip-chars-forward " \t"))
+        (current-column)))))
+
+;;; Heredoc / HTML helpers
+
+(defun php-indent--in-html-p ()
+  "Return non-nil if the current line lies in an HTML (non-PHP) region.
+A region between a `?>' and the next `<?php'/`<?='/`<?' is HTML.  When no
+PHP open/close tag precedes the line, it is treated as PHP."
+  (save-excursion
+    (goto-char (line-beginning-position))
+    (and (re-search-backward "<\\?php\\|<\\?=\\|<\\?\\|\\?>" nil t)
+         (string= (match-string 0) "?>"))))
+
+(defun php-indent--heredoc-closing-line-p ()
+  "Return non-nil if the current line is a heredoc/nowdoc closing marker.
+The closing marker is a bare identifier, optionally followed by `;', `,'
+or `)', on its own line."
+  (save-excursion
+    (beginning-of-line)
+    (looking-at "[ \t]*[A-Za-z_][A-Za-z0-9_]*[ \t]*[;,)]?[ \t]*$")))
+
+;;; Entry points
+
+;;;###autoload
+(defun php-indent-line ()
+  "Indent the current line as PHP.
+Intended for use as `indent-line-function'.  Lines inside an HTML region
+or inside a string/heredoc body are left unchanged; heredoc closing
+markers are forced to column zero."
+  (interactive)
+  (let* ((parse-status
+          (save-excursion (syntax-ppss (line-beginning-position))))
+         (offset (- (point) (save-excursion (back-to-indentation) (point)))))
+    (cond
+     ;; Do not touch lines that belong to an HTML (?> ... <?php) region.
+     ((php-indent--in-html-p) nil)
+     ;; Heredoc/nowdoc closing marker: force column zero.
+     ((and (nth 3 parse-status)
+           (php-indent--heredoc-closing-line-p))
+      (indent-line-to 0)
+      (when (> offset 0) (forward-char offset)))
+     ;; Inside a string or heredoc body: keep the current indentation.
+     ((nth 3 parse-status) nil)
+     (t
+      (indent-line-to (php-indent--proper-indentation parse-status))
+      (when (> offset 0) (forward-char offset))))))
+
+;;;###autoload
+(defun php-indent-region (start end)
+  "Indent each PHP line between START and END."
+  (interactive "r")
+  (save-excursion
+    (goto-char start)
+    (let ((end-marker (copy-marker end)))
+      (unwind-protect
+          (while (< (point) end-marker)
+            (unless (and (bolp) (eolp))
+              (php-indent-line))
+            (forward-line 1))
+        (set-marker end-marker nil)))))
+
+(provide 'php-indent)
+;;; php-indent.el ends here
diff --git a/lisp/php-keywords.el b/lisp/php-keywords.el
new file mode 100644
index 0000000000..1a37ef1be1
--- /dev/null
+++ b/lisp/php-keywords.el
@@ -0,0 +1,246 @@
+;;; php-keywords.el --- PHP language keyword tables (cc-mode independent)  -*- 
lexical-binding: t; -*-
+
+;; Copyright (C) 2026  Friends of Emacs-PHP development
+
+;; Author: USAMI Kenta <[email protected]>
+;; Maintainer: USAMI Kenta <[email protected]>
+;; URL: https://github.com/emacs-php/php-mode
+;; Keywords: languages php
+;; License: GPL-3.0-or-later
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This file extracts the PHP vocabulary that used to be scattered across
+;; the `c-lang-defconst' forms in lisp/php-cc-mode.el (roughly lines
+;; 367-609 as of the `php-cc-mode' freeze) into plain, cc-mode independent
+;; `defconst' lists and pre-compiled `regexp-opt' regexps.
+;;
+;; The purpose is to give the upcoming cc-mode independent `php-mode'
+;; font-lock implementation a single source of truth for PHP keywords,
+;; without requiring `cc-mode', `cc-langs' or `cc-fonts'.  Nothing
+;; requires this file yet: the current `php-mode' still fontifies through
+;; CC Mode, so these tables have no effect until that work lands.
+;;
+;; Naming convention: for every category NAME there is
+;;   - `php-keywords--NAME'      a list of keyword strings (lower-case)
+;;   - `php-keywords--NAME-re'   the same list compiled with
+;;                               `regexp-opt' using the 'symbols
+;;                               boundary, so it only matches whole
+;;                               tokens (e.g. "if" but not "iffy").
+;;
+;; Case folding: PHP keywords (control structures, declarations,
+;; statements, type names, `true'/`false'/`null') are case-insensitive
+;; in the PHP language itself (e.g. "IF", "If", "if" are equivalent).
+;; The lists below are kept lower-case only; callers that build
+;; `font-lock-keywords' entries from the `-re' regexps MUST bind
+;; `case-fold-search' to non-nil (or use `font-lock-defaults' with the
+;; KEYWORDS-CASE-FOLD slot set to t) while matching against them.
+;;
+;; This file holds only the *syntactic* vocabulary of the language.  Out
+;; of scope, and deliberately not duplicated here:
+;;
+;;   - built-in function names -- see lisp/php-defs.el;
+;;   - the magic constants (`__LINE__' and friends), which are written
+;;     upper-case and matched case-sensitively -- php.el owns that list
+;;     as `php-magical-constants', and both major modes build their
+;;     font-lock rule from it directly;
+;;   - predefined runtime constants such as `PHP_EOL' or `PHP_INT_MAX',
+;;     which are library symbols rather than language keywords.
+
+;;; Code:
+
+;; No dependency on `php' or any other package is required: these are
+;; plain data tables.  `regexp-opt' is autoloaded from `regexp-opt.el'
+;; which is always preloaded in stock Emacs.
+
+
+;;; 1. Control structures
+;;
+;; Source: php-cc-mode.el `c-lang-defconst' forms, php-cc branch:
+;;   - `c-block-stmt-2-kwds' (catch declare elseif for foreach if switch
+;;     while) -- "elseif" "for" "foreach" "if" "switch" "while" taken
+;;     from here; "catch" and "declare" are exception-handling /
+;;     declaration keywords and are filed under
+;;     `php-keywords--statements' / `php-keywords--declarations'
+;;     instead.
+;;   - `c-simple-stmt-kwds' (break continue die echo exit goto return
+;;     throw include include_once print require require_once) --
+;;     "break" "continue" "goto" "return" taken from here.
+;;   - `c-modifier-kwds' (abstract final static case readonly) -- "case"
+;;     is listed there because php-cc-mode.el also uses it for
+;;     `enum ... { case Foo; }' declarations, but as a keyword it is
+;;     overwhelmingly a `switch' label, so it is filed here.
+;;   - `c-other-kwds' -- "default" "endfor" "endforeach" "endif"
+;;     "endswitch" "endwhile" taken from here ("enddeclare" is filed
+;;     under `php-keywords--declarations' next to "declare").
+;;   - `c-inexpr-block-kwds' (match) -- "match" (PHP 8.0) taken from
+;;     here.
+;;
+;; Supplemented (not present as an explicit php-cc `c-lang-defconst'
+;; override; inherited by cc-mode from its java-mode/c base language
+;; and therefore invisible in the php-cc-specific forms, but required
+;; for a self-contained keyword table):
+;;   - "else" and "do" -- part of C-like `c-block-stmt-1-kwds' defaults
+;;     that php-cc-mode.el never overrides for PHP.
+(defconst php-keywords--control-structures
+  '("break" "case" "continue" "default" "do" "else" "elseif" "endfor"
+    "endforeach" "endif" "endswitch" "endwhile" "for" "foreach" "goto"
+    "if" "match" "return" "switch" "while")
+  "PHP control-structure keywords (case-insensitive).
+See the commentary in php-keywords.el for provenance.")
+
+(defconst php-keywords--control-structures-re
+  (regexp-opt php-keywords--control-structures 'symbols)
+  "`regexp-opt' of `php-keywords--control-structures', symbol-bounded.
+Match against buffer text with `case-fold-search' bound to non-nil.")
+
+
+;;; 2. Declarations and modifiers
+;;
+;; Source:
+;;   - `c-class-decl-kwds' -- "class" "trait" "interface" "enum" (PHP
+;;     8.1 backed enums).
+;;   - `c-typeless-decl-kwds' -- adds "function" "const" on top of
+;;     `c-class-decl-kwds'.
+;;   - `c-modifier-kwds' -- "abstract" "final" "static" "readonly" (PHP
+;;     8.1 readonly properties); "case" excluded, see above.
+;;   - `c-protection-kwds' -- "private" "protected" "public".
+;;   - `c-postfix-decl-spec-kwds' -- "implements" "extends".
+;;   - `c-type-list-kwds' -- adds "use" "namespace" "insteadof" (the
+;;     "@new" and "instanceof" entries in that list are pseudo-tokens /
+;;     operators, not declaration keywords, and are excluded; "new" is
+;;     filed under `php-keywords--statements').
+;;   - `c-other-block-decl-kwds' -- "namespace" (duplicate of above,
+;;     kept for provenance).
+;;   - `c-other-kwds' -- "global" "var", plus the declare-directive
+;;     pseudo-keywords "encoding" "ticks" "strict_types"; "declare"
+;;     itself comes from `c-block-stmt-2-kwds' and is filed here
+;;     (declarations), together with "enddeclare" which sits next to it
+;;     in `c-other-kwds'.
+;;
+;; Supplemented: none required -- PHP 8.1's "enum"/"readonly" and PHP
+;; 8.0's "match" (filed under control-structures) were already present
+;; in php-cc-mode.el's tables.
+(defconst php-keywords--declarations
+  '("abstract" "class" "const" "declare" "enddeclare" "encoding" "enum"
+    "extends" "final" "function" "global" "implements" "insteadof"
+    "interface" "namespace" "private" "protected" "public" "readonly"
+    "static" "strict_types" "ticks" "trait" "use" "var")
+  "PHP declaration and modifier keywords (case-insensitive).
+See the commentary in php-keywords.el for provenance.")
+
+(defconst php-keywords--declarations-re
+  (regexp-opt php-keywords--declarations 'symbols)
+  "`regexp-opt' of `php-keywords--declarations', symbol-bounded.
+Match against buffer text with `case-fold-search' bound to non-nil.")
+
+
+;;; 3. Statements and expression-level keywords
+;;
+;; Source:
+;;   - `c-simple-stmt-kwds' -- "die" "echo" "exit" "include"
+;;     "include_once" "print" "require" "require_once" "throw" taken
+;;     from here ("break" "continue" "goto" "return" filed under
+;;     control-structures instead).
+;;   - `c-lambda-kwds' -- "function" "use" (already covered by
+;;     declarations); nothing new added here.
+;;   - `c-operators' `(prefix "new" "clone")' entry -- "new" "clone".
+;;   - `c-operators' `(prefix "instanceof")' entry -- "instanceof".
+;;   - `c-other-kwds' -- "array" "as" "catch" "empty" "eval" "fn" (PHP
+;;     7.4 arrow functions) "isset" "list" "unset" "yield" "yield from"
+;;     "and" "or" "xor" taken from here.
+;;   - "finally" -- like "else"/"do" above, this is inherited from
+;;     cc-mode's java-mode/c base `c-block-stmt-1-kwds' and never
+;;     overridden by php-cc-mode.el, so it is supplemented here for
+;;     completeness of the try/catch/finally triple.
+;;
+;; Supplemented: none beyond "finally"; PHP 7.4's "fn" and the
+;; "yield from" two-word form were already present in php-cc-mode.el's
+;; `c-other-kwds'.
+(defconst php-keywords--statements
+  '("and" "array" "as" "catch" "clone" "die" "echo" "empty" "eval"
+    "exit" "finally" "fn" "include" "include_once" "instanceof" "isset"
+    "list" "new" "or" "print" "require" "require_once" "throw" "try"
+    "unset" "xor" "yield")
+  "PHP statement and expression keywords (case-insensitive).
+See the commentary in php-keywords.el for provenance.
+Note: the two-word form \"yield from\" is not representable as a single
+`regexp-opt' symbol entry; match it separately as
+\"\\\\_<yield\\\\_>\\\\s-+\\\\_<from\\\\_>\" if needed, or rely on
+\"yield\" alone matching the common one-word case.")
+
+(defconst php-keywords--statements-re
+  (regexp-opt php-keywords--statements 'symbols)
+  "`regexp-opt' of `php-keywords--statements', symbol-bounded.
+Match against buffer text with `case-fold-search' bound to non-nil.")
+
+
+;;; 4. Type names
+;;
+;; Source: `c-primitive-type-kwds' -- "int" "integer" "bool" "boolean"
+;; "float" "double" "real" "string" "object" "void" "mixed" "never"
+;; (PHP 8.1 "never" return type).
+;;
+;; Supplemented (PHP union/pseudo types that are commonly fontified as
+;; types but were not present in php-cc-mode.el's `c-primitive-type-kwds'):
+;;   - "array" "callable" "iterable" -- long-standing PHP pseudo-types
+;;     usable in type declarations, missing from the cc table (that
+;;     table only covered scalar/primitive-ish names; "array" itself
+;;     was instead reachable in php-cc-mode.el only indirectly via
+;;     `c-other-kwds').
+;;   - "false" "null" "true" -- PHP 8.0 allows "false"/"null" as
+;;     standalone return/param types and PHP 8.2 allows "true"; also
+;;     present in `c-constant-kwds' (see `php-keywords--constants').
+;;   - "self" "parent" "static" -- class-relative pseudo-types, valid in
+;;     return-type position since PHP 7.4/8.0; "self" and "static" were
+;;     present in php-cc-mode.el's `c-other-kwds', "parent" likewise.
+(defconst php-keywords--types
+  '("array" "bool" "boolean" "callable" "double" "false" "float" "int"
+    "integer" "iterable" "mixed" "never" "null" "object" "parent"
+    "real" "self" "static" "string" "true" "void")
+  "PHP type-hint / pseudo-type keywords (case-insensitive).
+See the commentary in php-keywords.el for provenance.")
+
+(defconst php-keywords--types-re
+  (regexp-opt php-keywords--types 'symbols)
+  "`regexp-opt' of `php-keywords--types', symbol-bounded.
+Match against buffer text with `case-fold-search' bound to non-nil.")
+
+
+;;; 5. Language constants
+;;
+;; Source: `c-constant-kwds' -- "true" "false" "null" (case-insensitive
+;; PHP language constants; stored lower-case here, same convention as
+;; every other list in this file).
+;;
+;; The magic constants (`__CLASS__', `__LINE__', ...) are intentionally
+;; NOT mirrored here.  They are not part of php-cc-mode.el's 367-609
+;; c-lang-defconst block either: both major modes fontify them straight
+;; from php.el's `php-magical-constants', which stays the single source
+;; of truth for that list.
+(defconst php-keywords--constants
+  '("true" "false" "null")
+  "PHP literal keywords `true', `false' and `null' (case-insensitive).
+The upper-case magic constants (`__LINE__' and friends) are matched
+case-sensitively and live in php.el's `php-magical-constants'.")
+
+(defconst php-keywords--constants-re
+  (regexp-opt php-keywords--constants 'symbols)
+  "`regexp-opt' of `php-keywords--constants', symbol-bounded.
+Match against buffer text with `case-fold-search' bound to non-nil.")
+
+(provide 'php-keywords)
+;;; php-keywords.el ends here


Reply via email to