;;; helm-make.el --- Select a Makefile target with helm

;; Copyright (C) 2014 Oleh Krehel

;; Author: Oleh Krehel <ohwoeowho@gmail.com>
;; URL: https://github.com/abo-abo/helm-make
;; Package-Version: 20160331.754
;; Version: 0.2.0
;; Package-Requires: ((helm "1.5.3") (projectile "0.11.0"))
;; Keywords: makefile

;; This file is not part of GNU Emacs

;; This file 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, 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.

;; For a full copy of the GNU General Public License
;; see <http://www.gnu.org/licenses/>.

;;; Commentary:
;;
;; A call to `helm-make' will give you a `helm' selection of this directory
;; Makefile's targets.  Selecting a target will call `compile' on it.

;;; Code:

(require 'helm)
(require 'helm-multi-match)

(declare-function ivy-read "ext:ivy")

(defgroup helm-make nil
  "Select a Makefile target with helm."
  :group 'convenience)

(defcustom helm-make-do-save nil
  "If t, save all open buffers visiting files from Makefile's directory."
  :type 'boolean
  :group 'helm-make)

(defcustom helm-make-build-dir ""
  "Specify a build directory for an out of source build.
The path should be relative to the project root.

When non-nil `helm-make-projectile' will first look in that directory for a
makefile."
  :type '(string)
  :group 'helm-make)
(make-variable-buffer-local 'helm-make-build-dir)

(defcustom helm-make-sort-targets nil
  "Whether targets shall be sorted.
If t, targets will be sorted as a final step before calling the
completion method.

HINT: If you are facing performance problems set this to nil.
This might be the case, if there are thousand of targets."
  :type 'boolean
  :group 'helm-make)

(defcustom helm-make-cache-targets nil
  "Whether to cache the targets or not.

If t, cache targets of Makefile. If `helm-make' or `helm-make-projectile'
gets called for the same Makefile again, and the Makefile hasn't changed
meanwhile, i.e. the modification time is `equal' to the cached one, reuse
the cached targets, instead of recomputing them. If nil do nothing.

You can reset the cache by calling `helm-make-reset-db'."
  :type 'boolean
  :group 'helm-make)

(defcustom helm-make-executable "make"
  "Store the name of make executable."
  :type 'string
  :group 'helm-make)

(defcustom helm-make-require-match t
  "When non-nil, don't allow selecting a target that's not on the list."
  :type 'boolean)

(defvar helm-make-command nil
  "Store the make command.")

(defvar helm-make-target-history nil
  "Holds the recently used targets.")

(defvar helm-make-makefile-names '("Makefile" "makefile" "GNUmakefile")
  "List of Makefile names which make recognizes.
An exception is \"GNUmakefile\", only GNU make unterstand it.")

(defun helm--make-action (target)
  "Make TARGET."
  (compile (format helm-make-command target)))

(defcustom helm-make-completion-method 'helm
  "Method to select a candidate from a list of strings."
  :type '(choice
          (const :tag "Helm" helm)
          (const :tag "Ido" ido)
          (const :tag "Ivy" ivy)))

;;;###autoload
(defun helm-make (&optional arg)
  "Call \"make -j ARG target\". Target is selected with completion."
  (interactive "p")
  (setq helm-make-command (format "%s -j%d %%s" helm-make-executable arg))
  (let ((makefile (helm--make-makefile-exists default-directory)))
    (if makefile
        (helm--make makefile)
      (error "No Makefile in %s" default-directory))))

(defun helm--make-target-list-qp (makefile)
  "Return the target list for MAKEFILE by parsing the output of \"make -nqp\"."
  (let ((default-directory (file-name-directory
                            (expand-file-name makefile)))
        targets target)
    (with-temp-buffer
      (insert
       (shell-command-to-string
        "make -nqp __BASH_MAKE_COMPLETION__=1 .DEFAULT 2>/dev/null"))
      (goto-char (point-min))
      (unless (re-search-forward "^# Files" nil t)
        (error "Unexpected \"make -nqp\" output"))
      (while (re-search-forward "^\\([^%$:#\n\t ]+\\):\\([^=]\\|$\\)" nil t)
        (setq target (match-string 1))
        (unless (or (save-excursion
		      (goto-char (match-beginning 0))
		      (forward-line -1)
		      (looking-at "^# Not a target:"))
                    (string-match "^\\([/a-zA-Z0-9_. -]+/\\)?\\." target))
          (push target targets))))
    targets))

(defun helm--make-target-list-default (makefile)
  "Return the target list for MAKEFILE by parsing it."
  (let (targets)
    (with-temp-buffer
      (insert-file-contents makefile)
      (goto-char (point-min))
      (while (re-search-forward "^\\([^: \n]+\\):" nil t)
        (let ((str (match-string 1)))
          (unless (string-match "^\\." str)
            (push str targets)))))
    targets))

(defcustom helm-make-list-target-method 'default
  "Method of obtaining the list of Makefile targets."
  :type '(choice
          (const :tag "Default" default)
          (const :tag "make -qp" qp)))

(defun helm--make-makefile-exists (base-dir &optional dir-list)
  "Check if one of `helm-make-makefile-names' exist in BASE-DIR.

Returns the absolute filename to the Makefile, if one exists,
otherwise nil.

If DIR-LIST is non-nil, also search for `helm-make-makefile-names'."
  (let* ((default-directory (file-truename base-dir))
         (makefiles
          (progn
            (unless (and dir-list (listp dir-list))
              (setq dir-list (list "")))
            (let (result)
              (dolist (dir dir-list)
                (dolist (makefile helm-make-makefile-names)
                  (push (expand-file-name makefile dir) result)))
              (reverse result)))))
    (cl-find-if 'file-exists-p makefiles)))

(defvar helm-make-db (make-hash-table :test 'equal)
  "An alist of Makefile and corresponding targets.")

(cl-defstruct helm-make-dbfile
  targets
  modtime
  sorted)

(defun helm--make-cached-targets (makefile)
  "Return cached targets of MAKEFILE.

If there are no cached targets for MAKEFILE, the MAKEFILE modification
time has changed, or `helm-make-cache-targets' is nil, parse the MAKEFILE,
and cache targets of MAKEFILE, if `helm-make-cache-targets' is t."
  (let* ((att (file-attributes makefile 'integer))
         (modtime (if att (nth 5 att) nil))
         (entry (gethash makefile helm-make-db nil))
         (new-entry (make-helm-make-dbfile))
         (targets (cond
                   ((and helm-make-cache-targets
                         entry
                         (equal modtime (helm-make-dbfile-modtime entry))
                         (helm-make-dbfile-targets entry))
                    (helm-make-dbfile-targets entry))
                   (t
                    (delete-dups (if (eq helm-make-list-target-method 'default)
                                     (helm--make-target-list-default makefile)
                                   (helm--make-target-list-qp makefile)))))))
    (when helm-make-sort-targets
      (unless (and helm-make-cache-targets
                   entry
                   (helm-make-dbfile-sorted entry))
        (setq targets (sort targets 'string<)))
      (setf (helm-make-dbfile-sorted new-entry) t))

    (when helm-make-cache-targets
      (setf (helm-make-dbfile-targets new-entry) targets
            (helm-make-dbfile-modtime new-entry) modtime)
      (puthash makefile new-entry helm-make-db))
    targets))

;;;###autoload
(defun helm-make-reset-cache ()
  "Reset cache, see `helm-make-cache-targets'."
  (interactive)
  (clrhash helm-make-db))

(defun helm--make (makefile)
  "Call make for MAKEFILE."
  (when helm-make-do-save
    (let* ((regex (format "^%s" default-directory))
           (buffers
            (cl-remove-if-not
             (lambda (b)
               (let ((name (buffer-file-name b)))
                 (and name
                      (string-match regex (expand-file-name name)))))
             (buffer-list))))
      (mapc
       (lambda (b)
         (with-current-buffer b
           (save-buffer)))
       buffers)))
  (let ((targets (helm--make-cached-targets makefile))
        (default-directory (file-name-directory makefile)))
    (delete-dups helm-make-target-history)
    (cl-case helm-make-completion-method
      (helm
       (helm :sources
             `((name . "Targets")
               (candidates . ,targets)
               (action . helm--make-action))
             :history 'helm-make-target-history
             :preselect (when helm-make-target-history
                          (format "^%s$" (car helm-make-target-history)))))
      (ivy
       (ivy-read "Target: "
                 targets
                 :history 'helm-make-target-history
                 :preselect (car helm-make-target-history)
                 :action 'helm--make-action
                 :require-match helm-make-require-match))
      (ido
       (let ((target (ido-completing-read
                      "Target: " targets
                      nil nil nil
                      'helm-make-target-history)))
         (when target
           (helm--make-action target)))))))

;;;###autoload
(defun helm-make-projectile (&optional arg)
  "Call `helm-make' for `projectile-project-root'.
ARG specifies the number of cores.

By default `helm-make-projectile' will look in `projectile-project-root'
followed by `projectile-project-root'/build, for a makefile.

You can specify an additional directory to search for a makefile by
setting the buffer local variable `helm-make-build-dir'."
  (interactive "p")
  (require 'projectile)
  (setq helm-make-command (format "%s -j%d %%s" helm-make-executable arg))
  (let ((makefile (helm--make-makefile-exists
                   (projectile-project-root)
                   (if (and (stringp helm-make-build-dir)
                            (not (string-match-p "\\`[ \t\n\r]*\\'" helm-make-build-dir)))
                       `(,helm-make-build-dir "" "build")
                     `(,@helm-make-build-dir "" "build")))))
    (if makefile
        (helm--make makefile)
      (error "No Makefile found for project %s" (projectile-project-root)))))

(provide 'helm-make)

;;; helm-make.el ends here
