;;; 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
;; Version: 1.24.2
;; 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.
;;
;; ### `php-project-get-phan-executable()'
;;
;; Return path to Phan executable file with the project settings overriding.
;; Phan is a static analyzer and LSP server implementation for PHP.
;; See https://github.com/phan/phan
;;
;; ## `.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.
;; - `php-project-phan-executable'
;;   - Path to project specific Phan executable file.
;;   - When not specified explicitly, it is automatically searched from
;;     Composer's dependency of the project and `exec-path'.
;;

;;; Code:
(eval-when-compile
  (require 'cl-lib))
(require 'projectile nil t)

;; 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"
  :group 'php-project
  :type 'boolean)

(defcustom php-project-use-projectile-to-detect-root nil
  "If `T' and projectile-mode is activated, use Projectile for root detection."
  :tag "PHP Project Use Projectile To Detect Root"
  :group 'php-project
  :type 'boolean)

;; 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-phan-executable nil
    "Path to phan executable file.")
  (put 'php-project-phan-executable 'safe-local-variable #'php-project--eval-bootstrap-scripts)

  (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."
  (cond
   ((and (stringp php-project-php-executable)
         (file-executable-p php-project-php-executable))
    php-project-php-executable)
   ((boundp 'php-executable) php-executable)
   (t (executable-find "php"))))

(defun php-project-get-phan-executable ()
  "Return path to phan executable file."
  (or (car-safe (php-project--eval-bootstrap-scripts
                 (list php-project-phan-executable
                       (cons 'root "vendor/bin/phan"))))
      (executable-find "phan")))

(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 path to current PHP project from DIR.

This function is compatible with `project-find-functions'."
  (let ((default-directory dir))
    (when-let (root (php-project-get-root-dir))
      (if (file-exists-p (expand-file-name ".git" root))
          (cons '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))))))
      (cl-loop for m in detect-method
               thereis (locate-dominating-file default-directory m)))))

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