;;; php-format.el --- Code reformatter for PHP buffer  -*- lexical-binding: t; -*-

;; Copyright (C) 2020  Friends of Emacs-PHP development

;; Author: USAMI Kenta <tadsan@zonu.me>
;; Created: 5 Mar 2023
;; Version: 0.1.0
;; Keywords: tools, php
;; URL: https://github.com/emacs-php/php-mode.el
;; 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 feature is for execute PHP code formatting tools.

;; ## Supported tools:
;;
;; - Easy Coding Standard (ecs) https://github.com/easy-coding-standard/easy-coding-standard
;; - PHP-CS-Fixer (php-cs-fixer) https://github.com/PHP-CS-Fixer/PHP-CS-Fixer
;; - PHP_CodeSniffer (phpcbf) https://github.com/squizlabs/PHP_CodeSniffer
;;
;; It supports both per-project and globally installed ones.
;;
;; ## How to use
;;
;; Add following line to setup function for php-mode.
;;
;;     (php-format-auto-mode +1)
;;
;; ## Customization
;;
;; These variables can be set either by dir-locals.el or by custom-set-variable.
;;
;; - php-format-auto-mode-hook-depth
;; - php-format-command
;; - php-format-command-dir
;; - php-format-default-idle-time
;; - php-format-result-display-method-alist
;;
;; ## Display methods
;;
;; How formatting is performed and how the results are displayed can be controlled
;; by the following keywords.
;;
;; - idle: Asynchronously apply formatting to idle time in Emacs using `run-with-idle-timer'
;; - async: Immediately execute an asynchronous process to apply formatting
;; - compile: Apply formatting using the compile command.  Doesn't lock, but results pop up
;; - silent: Apply formatting immediately and synchronously.
;;   No message is displayed, but Emacs is locked while it is being processed.
;; - nil: Apply formatting immediately and synchronously.
;;   Emacs will be locked until formatting is done and the result will pop up.
;;

;;; Code:
(require 'cl-lib)
(require 'php-project)

(defvar php-format-formatter-alist
  '((ecs :marker ("ecs.php")
         :command ("ecs" "check" "--fix" "--no-progress-bar" "--"))
    (php-cs-fixer :marker (".php-cs-fixer.dist.php" ".php-cs-fixer.php")
                  :command ("php-cs-fixer" "fix" "--show-progress=none"))
    (phpcbf :marker ("phpcs.xml.dist" "phpcs.xml")
            :command ("phpcbf"))))

(defvar php-format-lighter " phpf")
(defvar php-format-output-buffer " *PHP Format*")
(defvar php-format--exec-method nil)
(defvar php-format--idle-timer nil)

;; Customize variables
(defgroup php-format nil
  "Apply code reformat."
  :tag "PHP Format"
  :group 'php)

(defcustom php-format-auto-mode-hook-depth -50
  "A depth number in the range -100..100 for `add-hook'."
  :tag "PHP Format Auto Mode Hook Depth"
  :type 'integer
  :safe #'integerp
  :group 'php)

(defcustom php-format-command 'auto
  "A formatter symbol, or a list of command and arguments."
  :tag "PHP Format Command"
  :type '(choice (const :tag "Disabled reformat codes" nil)
                 (const :tag "Auto" 'auto)
                 (const :tag "Easy Coding Standard" 'ecs)
                 (const :tag "PHP-CS-Fixer" 'php-cs-fixer)
                 (const :tag "PHP Code Beautifier and Fixer" 'phpcbf)
                 (repeat :tag "Command and arguments" string))
  :safe (lambda (v) (or (symbolp v) (listp v)))
  :group 'php-format)

(defcustom php-format-command-dir "vendor/bin"
  "A relative path to the directory where formatting tool is installed."
  :tag "PHP Format Command"
  :type 'string
  :safe #'stringp
  :group 'php-format)

(defcustom php-format-default-idle-time 3
  "Number of seconds to wait idle before formatting."
  :tag "PHP Format Auto Mode Hook Depth"
  :type 'integer
  :safe #'integerp
  :group 'php)

(defcustom php-format-result-display-method-alist '((php-format-on-after-save-hook . idle)
                                          (php-format-this-buffer-file . silent)
                                          (php-format-project . compile))
  "An alist of misplay the result method of the formatting process."
  :tag "PHP Format Result Display Method"
  :type '(alist :key-type function
                :value-type symbol)
  :group 'php-format)

(defcustom php-format-disable-async-format-buffer-has-modified t
  "When set to non-NIL, disables async formatting if buffer is modified (unsaved)."
  :tag "PHP Format Disable Async Format Buffer Has Modified"
  :type 'boolean
  :group 'php-format)

;; Internal functions
(defsubst php-format--register-timer (sec command-args)
  "Register idle-timer with SEC and COMMAND-ARGS."
  (unless php-format--idle-timer
    (setq php-format--idle-timer
          (run-with-idle-timer sec nil #'php-format--execute-delayed-format
                               default-directory command-args))))

(defun php-format--execute-format (files)
  "Execute PHP formatter with FILES."
  (let* ((default-directory (php-project-get-root-dir))
         (command-args (php-format--get-command-args))
         command-line)
    (when (null command-args)
      (user-error "No available PHP formatter settings detected"))
    (setq command-args (append command-args files))
    (setq command-line (mapconcat #'shell-quote-argument command-args " "))
    (pcase php-format--exec-method
      (`(idle ,sec) (php-format--register-timer sec command-args))
      ('idle (php-format--register-timer php-format-default-idle-time command-args))
      ('async (apply #'call-process-shell-command (car command-args) nil nil nil
                     (append (cdr command-args) (list "&"))))
      ('compile (compile command-line))
      ('silent (shell-command-to-string command-line))
      ('nil (shell-command command-line))
      (_ (user-error "`%s' is unexpected php-format--exec-method" php-format--exec-method)))))

(defun php-format--get-command-args ()
  "Return a list of command and arguments."
  (if (listp php-format-command)
      php-format-command
    (let ((cmd php-format-command)
          args executable vendor)
      (when (eq 'auto cmd)
        (setq cmd (cl-loop for (sym . plist) in php-format-formatter-alist
                           for files = (plist-get plist :marker)
                           if (cl-find-if
                               (lambda (file) (file-exists-p (expand-file-name file default-directory)))
                               files)
                           return sym))
        (setq-local php-format-command cmd))
      (when-let (tup (plist-get (cdr-safe (assq cmd php-format-formatter-alist)) :command))
        (setq executable (car tup))
        (setq args (cdr tup))
        (setq vendor (expand-file-name executable (expand-file-name php-format-command-dir default-directory)))
        (cond
         ((file-exists-p vendor) (cons vendor args))
         ((executable-find executable) (cons executable args)))))))

(defun php-format--execute-delayed-format (dir command-args)
  "Asynchronously execute PHP format with COMMAND-ARGS in DIR."
  (setq php-format--idle-timer nil)
  (let ((default-directory dir))
    (when (not (and php-format-disable-async-format-buffer-has-modified
                    (buffer-modified-p)))
      (apply #'call-process-shell-command (car command-args) nil nil nil
             (append (cdr command-args) (list "&"))))))

;; Public functions and minor mode

;;;###autoload
(defun php-format-this-buffer-file ()
  "Apply format this buffer file."
  (interactive)
  (when php-format-command
    (when (null buffer-file-name)
      (user-error "This file has not yet been saved"))
    (when (file-remote-p buffer-file-name)
      (user-error "PHP Format feature does not yet support remote files"))
    (let ((php-format--exec-method (cdr-safe (assq 'php-format-this-buffer-file php-format-result-display-method-alist))))
      (php-format--execute-format (list buffer-file-name)))))

;;;###autoload
(defun php-format-project ()
  "Apply format this buffer file."
  (interactive)
  (unless php-format-command
    (user-error "Disabled `php-format-command' in this project"))
  (let ((php-format--exec-method (cdr-safe (assq 'php-format-project php-format-result-display-method-alist))))
    (php-format--execute-format nil)))

;;;###autoload
(defun php-format-on-after-save-hook ()
  "Apply format on after save hook."
  (when (and php-format-command buffer-file-name (not (file-remote-p buffer-file-name)))
    (let ((php-format--exec-method (cdr-safe (assq 'php-format-on-after-save-hook php-format-result-display-method-alist))))
    (php-format--execute-format nil))))

;;;###autoload
(define-minor-mode php-format-auto-mode
  "Automatically apply formatting when saving an edited file."
  :group 'php-format
  :lighter php-format-lighter
  (if php-format-auto-mode
      (add-hook 'after-save-hook 'php-format-on-after-save-hook php-format-auto-mode-hook-depth t)
    (remove-hook 'after-save-hook 'php-format-on-after-save-hook t)))

(provide 'php-format)
;;; php-format.el ends here
