;;; php-project.el --- Project support for PHP application  -*- lexical-binding: t; -*-

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

;; Author: USAMI Kenta <tadsan@zonu.me>
;; Keywords: tools, files
;; URL: 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:

;; Define project specific functions and variables for PHP application.
;;
;; ## API
;;
;; ### `php-project-get-root-dir()'
;;
;; Return root directory of current buffer file.  The root directory is
;; determined by several marker file or directory.
;;
;; ### `php-project-get-bootstrap-scripts()'
;;
;; Return list of path to bootstrap script file.
;;
;; ### `php-project-get-php-executable()'
;;
;; Return path to PHP executable file with the project settings overriding.
;;
;; ## `.dir-locals.el' support
;;
;; - `php-project-coding-style'
;;   - Symbol value of the coding style.  (ex.  `pear', `psr2')
;; - `php-project-root'
;;   - Symbol of marker file of project root.  (ex.  `git', `composer')
;;   - Full path to project root directory.  (ex.  "/path/to/your-project")
;; - `php-project-bootstrap-scripts'
;;   - List of path to bootstrap file of project.
;;     (ex.  (((root . "vendor/autoload.php") (root . "inc/bootstrap.php")))
;; - `php-project-php-executable'
;;   - Path to project specific PHP executable file.
;;   - If you want to use a file different from the system wide `php' command.
;;

;;; Code:
(eval-when-compile
  (require 'cl-lib))
(require 'php-core)
(require 'project)
;; Projectile is optional and only used at runtime through `fboundp' guards.
;; `projectile-mode' being active already implies the library is loaded, so we
;; deliberately avoid pulling in the whole of Projectile just by requiring us.

;; Constants
(defconst php-project-composer-autoloader "vendor/autoload.php")

;; Custom variables
(defgroup php-project nil
  "Major mode for editing PHP code."
  :tag "PHP Project"
  :prefix "php-project-"
  :group 'php)

(defcustom php-project-auto-detect-etags-file nil
  "If `T', automatically detect etags file when file is opened."
  :tag "PHP Project Auto Detect Etags File"
  :type 'boolean)

(defcustom php-project-use-projectile-to-detect-root nil
  "If `T' and projectile-mode is activated, use Projectile for root detection.

This option predates Projectile 3, which registers itself on
`project-find-functions'.  `php-project-get-root-dir' now falls back to
`project-current' when no PHP marker is found, so Projectile (and any other
`project.el' backend) is consulted automatically.  Prefer that path instead."
  :tag "PHP Project Use Projectile To Detect Root"
  :type 'boolean)
(make-obsolete-variable 'php-project-use-projectile-to-detect-root
                        "rely on `project-find-functions'; \
`php-project-get-root-dir' falls back to `project-current' automatically."
                        "1.28.0")

;; Variables
(defvar php-project-available-root-files
  '((projectile ".projectile")
    (composer   "composer.json" "composer.lock")
    (git        ".git")
    (mercurial  ".hg")
    (subversion ".svn")
    ;; NOTICE: This method does not detect the top level of .editorconfig
    ;;         However, we can integrate it by adding the editorconfig.el's API.
    ;;(editorconfig . ".editorconfig")
    ))

;; Buffer local variables

;;;###autoload
(progn
  (defvar-local php-project-root 'auto
    "Method of searching for the top level directory.

`auto' (default)
      Try to search file in order of `php-project-available-root-files'.

SYMBOL
      Key of `php-project-available-root-files'.

STRING
      A file/directory name of top level marker.
      If the string is an actual directory path, it is set as the absolute path
      of the root directory, not the marker.")
  (put 'php-project-root 'safe-local-variable
       #'(lambda (v) (or (stringp v) (assq v php-project-available-root-files))))

  (defvar-local php-project-etags-file nil)
  (put 'php-project-etags-file 'safe-local-variable
       #'(lambda (v) (or (functionp v)
                         (eq v t)
                         (php-project--eval-bootstrap-scripts v))))

  (defvar-local php-project-bootstrap-scripts nil
    "List of path to bootstrap php script file.

The ideal bootstrap file is silent, it only includes dependent files,
defines constants, and sets the class loaders.")
  (put 'php-project-bootstrap-scripts 'safe-local-variable #'php-project--eval-bootstrap-scripts)

  (defvar-local php-project-php-executable nil
    "Path to php executable file.")
  (put 'php-project-php-executable 'safe-local-variable
       #'(lambda (v) (and (stringp v) (file-executable-p v))))

  (defvar-local php-project-coding-style nil
    "Symbol value of the coding style of the project that PHP major mode refers to.

Typically it is `pear', `drupal', `wordpress', `symfony2' and `psr2'.")
  (put 'php-project-coding-style 'safe-local-variable #'symbolp)

  (defvar-local php-project-align-lines t
    "If T, automatically turn on `php-align-mode' by `php-align-setup'.")
  (put 'php-project-align-lines 'safe-local-variable #'booleanp)

  (defvar-local php-project-php-file-as-template 'auto
    "
`auto' (default)
      Automatically switch to mode for template when HTML tag detected in file.

`t'
      Switch all PHP files in that directory to mode for HTML template.

`nil'
      Any .php  in that directory is just a PHP script.

\(\(PATTERN . SYMBOL))
      Alist of file name pattern regular expressions and the above symbol pairs.
      PATTERN is regexp pattern.
")
  (put 'php-project-php-file-as-template 'safe-local-variable #'php-project--validate-php-file-as-template)

  (defvar-local php-project-repl nil
    "Function name or path to REPL (interactive shell) script.")
  (put 'php-project-repl 'safe-local-variable
       #'(lambda (v) (or (functionp v)
                         (php-project--eval-bootstrap-scripts v))))

  (defvar-local php-project-unit-test nil
    "Function name or path to unit test script.")
  (put 'php-project-unit-test 'safe-local-variable
       #'(lambda (v) (or (functionp v)
                         (php-project--eval-bootstrap-scripts v))))

  (defvar-local php-project-deploy nil
    "Function name or path to deploy script.")
  (put 'php-project-deploy 'safe-local-variable
       #'(lambda (v) (or (functionp v)
                         (php-project--eval-bootstrap-scripts v))))

  (defvar-local php-project-build nil
    "Function name or path to build script.")
  (put 'php-project-build 'safe-local-variable
       #'(lambda (v) (or (functionp v)
                         (php-project--eval-bootstrap-scripts v))))

  (defvar-local php-project-server-start nil
    "Function name or path to server-start script.")
  (put 'php-project-server-start 'safe-local-variable
       #'(lambda (v) (or (functionp v)
                         (php-project--eval-bootstrap-scripts v)))))

;; Functions
(defun php-project--validate-php-file-as-template (val)
  "Return T when `VAL' is valid list of safe ."
  (cond
   ((null val) t)
   ((memq val '(t auto)) t)
   ((listp val)
    (cl-loop for v in val
             always (and (consp v)
                         (stringp (car v))
                         (php-project--validate-php-file-as-template (cdr v)))))
   (t nil)))

(defun php-project--eval-bootstrap-scripts (val)
  "Return T when `VAL' is valid list of safe bootstrap php script."
  (cond
   ((stringp val) (and (file-exists-p val) val))
   ((eq 'composer val)
    (let ((path (expand-file-name php-project-composer-autoloader (php-project-get-root-dir))))
      (and (file-exists-p path) path)))
   ((and (consp val) (eq 'root (car val)) (stringp (cdr val)))
    (let ((path (expand-file-name (cdr val) (php-project-get-root-dir))))
      (and (file-exists-p path) path)))
   ((null val) nil)
   ((listp val)
    (cl-loop for v in val collect (php-project--eval-bootstrap-scripts v)))
   (t nil)))

(defun php-project-get-php-executable ()
  "Return path to PHP executable file."
  (if (and (stringp php-project-php-executable)
           (file-executable-p php-project-php-executable))
      php-project-php-executable
    php-executable))

(defun php-project-get-file-html-template-type (filename)
  "Return symbol T, NIL or `auto' by `FILENAME'."
  (cond
   ((not php-project-php-file-as-template) nil)
   ((eq t php-project-php-file-as-template) t)
   ((eq 'auto php-project-php-file-as-template) 'auto)
   ((listp php-project-php-file-as-template)
    (assoc-default filename php-project-php-file-as-template #'string-match-p))
   (t (prog1 nil
        (warn "php-project-php-file-as-template is unexpected format")))))

(defun php-project-apply-local-variables ()
  "Apply php-project variables to local variables."
  (when (null tags-file-name)
    (when (or (and php-project-auto-detect-etags-file
                   (null php-project-etags-file))
              (eq php-project-etags-file t))
      (let ((tags-file (expand-file-name "TAGS" (php-project-get-root-dir))))
        (when (file-exists-p tags-file)
          (setq-local php-project-etags-file tags-file))))
    (when php-project-etags-file
      (setq-local tags-file-name (php-project--eval-bootstrap-scripts php-project-etags-file)))))
;;;###autoload
(defun php-project-get-bootstrap-scripts ()
  "Return list of bootstrap script."
  (let ((scripts (php-project--eval-bootstrap-scripts php-project-bootstrap-scripts)))
    (if (stringp scripts) (list scripts) scripts)))

;;;###autoload
(defun php-project-get-root-dir ()
  "Return path to current PHP project."
  (if (and (stringp php-project-root) (file-directory-p php-project-root))
      php-project-root
    (php-project--detect-root-dir)))

;;;###autoload
(defun php-project-project-find-function (dir)
  "Return the PHP project containing DIR.

This function is compatible with `project-find-functions'.  Register it with a
low priority so that it only supplements the built-in detection, for example:

    (add-hook \\='project-find-functions #\\='php-project-project-find-function 90)

When the detected root is under version control the result is delegated to
`project-try-vc', which yields the representation expected by the running
Emacs (a plain \\='(vc . ROOT) cons is only valid on Emacs 27 and breaks the
3-element \\='(vc BACKEND ROOT) form used since Emacs 28).  Otherwise a
`transient' project rooted at the PHP project directory is returned."
  (let ((default-directory dir))
    (when-let* ((root (php-project-get-root-dir)))
      (or (and (file-exists-p (expand-file-name ".git" root))
               (fboundp 'project-try-vc)
               (project-try-vc root))
          (cons 'transient root)))))

(defun php-project--detect-root-dir ()
  "Return detected project root."
  (if (and php-project-use-projectile-to-detect-root
           (bound-and-true-p projectile-mode)
           (fboundp 'projectile-project-root))
      (projectile-project-root default-directory)
    (let ((detect-method
           (cond
            ((stringp php-project-root) (list php-project-root))
            ((eq php-project-root 'auto)
             (cl-loop for m in php-project-available-root-files
                      append (cdr m)))
            (t (cdr-safe (assq php-project-root php-project-available-root-files))))))
      (or (cl-loop for m in detect-method
                   thereis (locate-dominating-file default-directory m))
          (php-project--detect-root-by-project-el)))))

(defun php-project--detect-root-by-project-el ()
  "Return the root reported by `project.el', or nil.

Used as a last resort when no PHP-specific marker is found.  This lets
`project.el' backends contribute their own detection, including Projectile 3
\(which registers itself on `project-find-functions') and
`project-vc-extra-root-markers'.  `php-project-project-find-function' is
temporarily removed from the hook to avoid recursing back into this function."
  ;; `project-root' only exists on Emacs 28.1+; on Emacs 27 there is no
  ;; non-obsolete accessor, so the fallback simply does nothing there.
  (when (fboundp 'project-root)
    (let ((project-find-functions
           (remq #'php-project-project-find-function project-find-functions)))
      (when-let* ((project (project-current)))
        (project-root project)))))

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