;;; guix-base.el --- Common definitions

;; Copyright © 2014 Alex Kost <alezost@gmail.com>

;; This file is part of GNU Guix.

;; GNU Guix 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.

;; GNU Guix 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 <http://www.gnu.org/licenses/>.

;;; Commentary:

;; This file provides some base and common definitions for guix.el
;; package.

;; List and info buffers have many common patterns that are defined
;; using `guix-define-buffer-type' macro from this file.

;;; Code:

(require 'cl-lib)
(require 'guix-backend)
(require 'guix-utils)


;;; Profiles

(defvar guix-user-profile
  (expand-file-name "~/.guix-profile")
  "User profile.")

(defvar guix-default-profile
  (concat (or (getenv "NIX_STATE_DIR") "/var/guix")
          "/profiles/per-user/"
          (getenv "USER")
          "/guix-profile")
  "Default Guix profile.")

(defvar guix-current-profile guix-default-profile
  "Current profile.")

(defun guix-set-current-profile (path)
  "Set `guix-current-profile' to PATH.
Interactively, prompt for PATH.  With prefix, use
`guix-default-profile'."
  (interactive
   (list (if current-prefix-arg
             guix-default-profile
           (read-file-name "Set profile: "
                           (file-name-directory guix-current-profile)))))
  (let ((path (directory-file-name (expand-file-name path))))
    (setq guix-current-profile
          (if (string= path guix-user-profile)
              guix-default-profile
            path))
    (message "Current profile has been set to '%s'."
             guix-current-profile)))


;;; Parameters of the entries

(defvar guix-param-titles
  '((package
     (id                . "ID")
     (name              . "Name")
     (version           . "Version")
     (license           . "License")
     (synopsis          . "Synopsis")
     (description       . "Description")
     (home-url          . "Home page")
     (outputs           . "Outputs")
     (inputs            . "Inputs")
     (native-inputs     . "Native inputs")
     (propagated-inputs . "Propagated inputs")
     (location          . "Location")
     (installed         . "Installed"))
    (installed
     (path              . "Installed path")
     (dependencies      . "Dependencies")
     (output            . "Output"))
    (generation
     (id                . "ID")
     (number            . "Number")
     (prev-number       . "Previous number")
     (path              . "Path")
     (time              . "Time")))
  "List for defining titles of entry parameters.
Titles are used for displaying information about entries.
Each element of the list has a form:

  (ENTRY-TYPE . ((PARAM . TITLE) ...))")

(defun guix-get-param-title (entry-type param)
  "Return title of an ENTRY-TYPE entry parameter PARAM."
  (or (guix-get-key-val guix-param-titles
                        entry-type param)
      (prog1 (symbol-name param)
        (message "Couldn't find title for '%S %S'."
                 entry-type param))))

(defun guix-get-name-spec (name version &optional output)
  "Return Guix package specification by its NAME, VERSION and OUTPUT."
  (concat name "-" version
          (when output (concat ":" output))))

(defun guix-get-full-name (entry &optional output)
  "Return name specification of the package ENTRY and OUTPUT."
  (guix-get-name-spec (guix-get-key-val entry 'name)
                      (guix-get-key-val entry 'version)
                      output))

(defun guix-get-installed-outputs (entry)
  "Return list of installed outputs for the package ENTRY."
  (mapcar (lambda (installed-entry)
            (guix-get-key-val installed-entry 'output))
          (guix-get-key-val entry 'installed)))

(defun guix-get-entry-by-id (id entries)
  "Return entry from ENTRIES by entry ID."
  (cl-find-if (lambda (entry)
                (equal id (guix-get-key-val entry 'id)))
              entries))


;;; Location of the packages

(defvar guix-directory nil
  "Default Guix directory.
If it is not set by a user, it is set after starting Guile REPL.
This directory is used to define location of the packages.")

(defun guix-set-directory ()
  "Set `guix-directory' if needed."
  (or guix-directory
      (setq guix-directory
            (guix-eval-read "%guix-dir"))))

(add-hook 'guix-after-start-repl-hook 'guix-set-directory)

(defun guix-find-location (location)
  "Go to LOCATION of a package.
LOCATION is a string of the form:

  \"PATH:LINE:COLUMN\"

If PATH is relative, it is considered to be relative to
`guix-directory'."
  (cl-multiple-value-bind (path line col)
      (split-string location ":")
    (let ((file (expand-file-name path guix-directory))
          (line (string-to-number line))
          (col  (string-to-number col)))
      (find-file file)
      (goto-char (point-min))
      (forward-line (- line 1))
      (move-to-column col)
      (recenter 1))))


;;; Common definitions for buffer types

(defvar-local guix-entries nil
  "List of the currently displayed entries.
Each element of the list is alist with entry info of the
following form:

  ((PARAM . VAL) ...)

PARAM is a name of the entry parameter.
VAL is a value of this parameter.")
(put 'guix-entries 'permanent-local t)

(defvar-local guix-search-type nil
  "Type of the current search.")
(put 'guix-search-type 'permanent-local t)

(defvar-local guix-search-vals nil
  "Values of the current search.")
(put 'guix-search-vals 'permanent-local t)

(defsubst guix-set-vars (entries search-type search-vals)
  (setq guix-entries entries
        guix-search-type search-type
        guix-search-vals search-vals))

(defmacro guix-define-buffer-type (buf-type entry-type &rest args)
  "Define common stuff for BUF-TYPE buffers for displaying entries.

ENTRY-TYPE is a type of displayed entries (see
`guix-get-entries').

In the text below TYPE means ENTRY-TYPE-BUF-TYPE.

This macro defines `guix-TYPE-mode', a custom group, several user
variables and the following functions:

  - `guix-TYPE-get-params-for-receiving'
  - `guix-TYPE-revert'
  - `guix-TYPE-redisplay'
  - `guix-TYPE-make-history-item'
  - `guix-TYPE-set'
  - `guix-TYPE-show'
  - `guix-TYPE-get-show'

The following stuff should be defined outside this macro:

  - `guix-BUF-TYPE-mode' - parent mode for the defined mode.

  - `guix-BUF-TYPE-insert-entries' - function for inserting
  entries in the current buffer; it is called with 2 arguments:
  entries of the form of `guix-entries' and ENTRY-TYPE.

  - `guix-BUF-TYPE-get-displayed-params' - function returning a
  list of parameters displayed in the current buffer; it is
  called with ENTRY-TYPE as argument.

  - `guix-TYPE-mode-initialize' (optional) - function for
  additional mode settings; it is called without arguments.

Remaining argument (ARGS) should have a form [KEYWORD VALUE] ...  The
following keywords are available:

  - `:required' - default value for the defined
    `guix-TYPE-required-params' variable.

  - `:history-size' - default value for the defined
    `guix-TYPE-history-size' variable.

  - `:revert' - default value for the defined
    `guix-TYPE-revert-no-confirm' variable."
  (let* ((entry-type-str (symbol-name entry-type))
         (buf-type-str   (symbol-name buf-type))
         (Entry-type-str (capitalize entry-type-str))
         (Buf-type-str   (capitalize buf-type-str))
         (entry-str      (concat entry-type-str " entries"))
         (buf-str        (concat buf-type-str " buffer"))
         (prefix         (concat "guix-" entry-type-str "-" buf-type-str))
         (group          (intern prefix))
         (mode-map-str   (concat prefix "-mode-map"))
         (mode-map       (intern mode-map-str))
         (parent-mode    (intern (concat "guix-" buf-type-str "-mode")))
         (mode           (intern (concat prefix "-mode")))
         (mode-init-fun  (intern (concat prefix "-mode-initialize")))
         (buf-name-var   (intern (concat prefix "-buffer-name")))
         (revert-var     (intern (concat prefix "-revert-no-confirm")))
         (revert-fun     (intern (concat prefix "-revert")))
         (redisplay-fun  (intern (concat prefix "-redisplay")))
         (history-var    (intern (concat prefix "-history-size")))
         (history-fun    (intern (concat prefix "-make-history-item")))
         (params-var     (intern (concat prefix "-required-params")))
         (params-fun     (intern (concat prefix "-get-params-for-receiving")))
         (set-fun        (intern (concat prefix "-set")))
         (show-fun       (intern (concat prefix "-show")))
         (get-show-fun   (intern (concat prefix "-get-show")))
         (revert-val     nil)
         (history-val    20)
         (params-val     '(id)))

    ;; Process the keyword args.
    (while (keywordp (car args))
      (pcase (pop args)
	(`:required     (setq params-val (pop args)))
	(`:history-size (setq history-val (pop args)))
	(`:revert       (setq revert-val (pop args)))
	(_ (pop args))))

    `(progn
       (defgroup ,group nil
         ,(concat Buf-type-str " buffer with " entry-str ".")
         :prefix ,(concat prefix "-")
         :group ',(intern (concat "guix-" buf-type-str)))

       (defcustom ,buf-name-var ,(format "*Guix %s %s*"
                                         Entry-type-str Buf-type-str)
         ,(concat "Default name of the " buf-str " for displaying " entry-str ".")
         :type 'string
         :group ',group)

       (defcustom ,history-var ,history-val
         ,(concat "Maximum number of items saved in the history of the " buf-str ".\n"
                  "If 0, the history is disabled.")
         :type 'integer
         :group ',group)

       (defcustom ,revert-var ,revert-val
         ,(concat "If non-nil, do not ask to confirm for reverting the " buf-str ".")
         :type 'boolean
         :group ',group)

       (defvar ,params-var ',params-val
         ,(concat "List of required " entry-type-str " parameters.\n\n"
                  "Displayed parameters and parameters from this list are received\n"
                  "for each " entry-type-str ".\n\n"
                  "May be a special value `all', in which case all supported\n"
                  "parameters are received (this may be very slow for a big number\n"
                  "of entries).\n\n"
                  "Do not remove `id' from this list as it is required for\n"
                  "identifying an entry."))

       (define-derived-mode ,mode ,parent-mode ,(concat "Guix-" Buf-type-str)
         ,(concat "Major mode for displaying information about " entry-str ".\n\n"
                  "\\{" mode-map-str "}")
         (setq-local revert-buffer-function ',revert-fun)
         (setq-local guix-history-size ,history-var)
         (and (fboundp ',mode-init-fun) (,mode-init-fun)))

       (let ((map ,mode-map))
         (define-key map (kbd "l") 'guix-history-back)
         (define-key map (kbd "r") 'guix-history-forward)
         (define-key map (kbd "g") 'revert-buffer)
         (define-key map (kbd "R") ',redisplay-fun)
         (define-key map (kbd "C-c C-z") 'guix-switch-to-repl))

       (defun ,params-fun ()
         ,(concat "Return " entry-type-str " parameters that should be received.")
         (unless (equal ,params-var 'all)
           (cl-union ,params-var
                     (,(intern (concat "guix-" buf-type-str "-get-displayed-params"))
                      ',entry-type))))

       (defun ,revert-fun (_ignore-auto noconfirm)
         "Update information in the current buffer.
The function is suitable for `revert-buffer-function'.
See `revert-buffer' for the meaning of NOCONFIRM."
         (when (or ,revert-var
                   noconfirm
                   (y-or-n-p "Update current information? "))
           (let ((entries (guix-get-entries ',entry-type guix-search-type
                                            guix-search-vals (,params-fun))))
             (,set-fun entries guix-search-type guix-search-vals t))))

       (defun ,redisplay-fun ()
         "Redisplay current information.
This function will not update the information, use
\"\\[revert-buffer]\" if you want the full update."
         (interactive)
         (,show-fun guix-entries)
         (guix-result-message guix-entries ',entry-type
                              guix-search-type guix-search-vals))

       (defun ,history-fun ()
         "Make and return a history item for the current buffer."
         (list (lambda (entries search-type search-vals)
                 (,show-fun entries)
                 (guix-set-vars entries search-type search-vals)
                 (guix-result-message entries ',entry-type
                                      search-type search-vals))
               guix-entries guix-search-type guix-search-vals))

       (defun ,set-fun (entries search-type search-vals &optional history-replace)
         ,(concat "Set up the " buf-str " for displaying " entry-str ".\n\n"
                  "Display ENTRIES, set variables and make history item.\n\n"
                  "ENTRIES should have a form of `guix-entries'.\n\n"
                  "See `guix-get-entries' for the meaning of SEARCH-TYPE and\n"
                  "SEARCH-VALS.\n\n"
                  "If HISTORY-REPLACE is non-nil, replace current history item,\n"
                  "otherwise add the new one.")
         (when entries
           (let ((buf (if (eq major-mode ',mode)
                          (current-buffer)
                        (get-buffer-create ,buf-name-var))))
             (with-current-buffer buf
               (,show-fun entries)
               (guix-set-vars entries search-type search-vals)
               (funcall (if history-replace
                            #'guix-history-replace
                          #'guix-history-add)
                        (,history-fun)))
             (pop-to-buffer buf
                            '((display-buffer-reuse-window
                               display-buffer-same-window)))))
         (guix-result-message entries ',entry-type
                              search-type search-vals))

       (defun ,show-fun (entries)
         ,(concat "Display " entry-type-str " ENTRIES in the current " buf-str ".")
         (let ((inhibit-read-only t))
           (erase-buffer)
           (,mode)
           (,(intern (concat "guix-" buf-type-str "-insert-entries"))
            entries ',entry-type)
           (goto-char (point-min))))

       (defun ,get-show-fun (search-type &rest search-vals)
         ,(concat "Search for " entry-str " and show results in the " buf-str ".\n"
                  "See `guix-get-entries' for the meaning of SEARCH-TYPE and\n"
                  "SEARCH-VALS.")
         (let ((entries (guix-get-entries ',entry-type search-type
                                          search-vals (,params-fun))))
           (,set-fun entries search-type search-vals))))))

(put 'guix-define-buffer-type 'lisp-indent-function 'defun)


;;; Messages

(defvar guix-messages
  '((package
     (id
      (0 "Packages not found.")
      (1 "")
      (many "%d packages." count))
     (name
      (0 "The package '%s' not found." val)
      (1 "A single package with name '%s'." val)
      (many "%d packages with '%s' name." count val))
     (regexp
      (0 "No packages matching '%s'." val)
      (1 "A single package matching '%s'." val)
      (many "%d packages matching '%s'." count val))
     (all-available
      (0 "No packages are available for some reason.")
      (1 "A single available package (that's strange).")
      (many "%d available packages." count))
     (newest-available
      (0 "No packages are available for some reason.")
      (1 "A single newest available package (that's strange).")
      (many "%d newest available packages." count))
     (installed
      (0 "No installed packages.")
      (1 "A single installed package.")
      (many "%d installed packages." count))
     (obsolete
      (0 "No obsolete packages.")
      (1 "A single obsolete package.")
      (many "%d obsolete packages." count))
     (generation
      (0 "No packages installed in generation %d." val)
      (1 "A single package installed in generation %d." val)
      (many "%d packages installed in generation %d." count val)))
    (generation
     (id
      (0 "Generations not found.")
      (1 "")
      (many "%d generations." count))
     (last
      (0 "No available generations.")
      (1 "The last generation.")
      (many "%d last generations." count))
     (all
      (0 "No available generations.")
      (1 "A single available generation.")
      (many "%d available generations." count)))))

(defun guix-result-message (entries entry-type search-type search-vals)
  "Display an appropriate message after displaying ENTRIES."
  (let* ((val (car search-vals))
         (count (length entries))
         (count-key (if (> count 1) 'many count))
         (msg-spec (guix-get-key-val guix-messages
                                     entry-type search-type count-key))
         (format (car msg-spec))
         (args (cdr msg-spec)))
    (mapc (lambda (subst)
            (setq args (cl-substitute (car subst) (cdr subst) args)))
          (list (cons count 'count)
                (cons val 'val)))
    (apply #'message format args)))


;;; Getting info about packages and generations

(defun guix-get-entries (entry-type search-type search-vals &optional params)
  "Search for entries of ENTRY-TYPE.

Call an appropriate scheme function and return a list of the
form of `guix-entries'.

ENTRY-TYPE should be one of the following symbols: `package' or
`generation'.

SEARCH-TYPE may be one of the following symbols:

- If ENTRY-TYPE is `package': `id', `name', `regexp',
  `all-available', `newest-available', `installed', `obsolete',
  `generation'.

- If ENTRY-TYPE is `generation': `id', `last', `all'.

PARAMS is a list of parameters for receiving.  If nil, get
information with all available parameters."
  (guix-eval-read (guix-make-guile-expression
                   'get-entries
                   guix-current-profile params
                   entry-type search-type search-vals)))


;;; Actions on packages and generations

(defcustom guix-operation-confirm t
  "If nil, do not prompt to confirm an operation."
  :type 'boolean
  :group 'guix)

(defcustom guix-use-substitutes t
  "If non-nil, use substitutes for the Guix packages."
  :type 'boolean
  :group 'guix)

(defvar guix-dry-run nil
  "If non-nil, do not perform the real actions, just simulate.")

(defvar guix-temp-buffer-name " *Guix temp*"
  "Name of a buffer used for displaying info before executing operation.")

(defun guix-process-package-actions (&rest actions)
  "Process package ACTIONS.
Each action is a list of the form:

  (ACTION-TYPE PACKAGE-SPEC ...)

ACTION-TYPE is one of the following symbols: `install',
`upgrade', `remove'/`delete'.
PACKAGE-SPEC should have the following form: (ID [OUTPUT] ...)."
  (let (install upgrade remove)
    (mapc (lambda (action)
            (let ((action-type (car action))
                  (specs (cdr action)))
              (cl-case action-type
                (install (setq install (append install specs)))
                (upgrade (setq upgrade (append upgrade specs)))
                ((remove delete) (setq remove (append remove specs))))))
          actions)
    (when (guix-continue-package-operation-p
           :install install :upgrade upgrade :remove remove)
      (guix-eval-in-repl
       (guix-make-guile-expression
        'process-package-actions guix-current-profile
        :install install :upgrade upgrade :remove remove
        :use-substitutes? (or guix-use-substitutes 'f)
        :dry-run? (or guix-dry-run 'f))))))

(cl-defun guix-continue-package-operation-p (&key install upgrade remove)
  "Return non-nil if a package operation should be continued.
Ask a user if needed (see `guix-operation-confirm').
INSTALL, UPGRADE, REMOVE are 'package action specifications'.
See `guix-process-package-actions' for details."
  (or (null guix-operation-confirm)
      (let* ((entries (guix-get-entries
                       'package 'id
                       (list (append (mapcar #'car install)
                                     (mapcar #'car upgrade)
                                     (mapcar #'car remove)))
                       '(id name version location)))
             (install-strings (guix-get-package-strings install entries))
             (upgrade-strings (guix-get-package-strings upgrade entries))
             (remove-strings  (guix-get-package-strings remove entries)))
        (if (or install-strings upgrade-strings remove-strings)
            (let ((buf (get-buffer-create guix-temp-buffer-name)))
              (with-current-buffer buf
                (setq-local cursor-type nil)
                (setq buffer-read-only nil)
                (erase-buffer)
                (guix-insert-package-strings install-strings "install")
                (guix-insert-package-strings upgrade-strings "upgrade")
                (guix-insert-package-strings remove-strings "remove")
                (let ((win (temp-buffer-window-show
                            buf
                            '((display-buffer-reuse-window
                               display-buffer-at-bottom)
                              (window-height . fit-window-to-buffer)))))
                  (prog1 (y-or-n-p "Continue operation? ")
                    (quit-window nil win)))))
          (message "Nothing to be done.  If the REPL was restarted, information is not up-to-date.")
          nil))))

(defun guix-get-package-strings (specs entries)
  "Return short package descriptions for performing package actions.
See `guix-process-package-actions' for the meaning of SPECS.
ENTRIES is a list of package entries to get info about packages."
  (delq nil
        (mapcar
         (lambda (spec)
           (let* ((id (car spec))
                  (outputs (cdr spec))
                  (entry (guix-get-entry-by-id id entries)))
             (when entry
               (let ((location (guix-get-key-val entry 'location)))
                 (concat (guix-get-full-name entry)
                         (when outputs
                           (concat ":"
                                   (mapconcat #'identity outputs ",")))
                         (when location
                           (concat "\t(" location ")")))))))
         specs)))

(defun guix-insert-package-strings (strings action)
  "Insert information STRINGS at point for performing package ACTION."
  (when strings
    (insert "Package(s) to " (guix-get-string action 'bold) ":\n")
    (mapc (lambda (str)
            (insert "  " str "\n"))
          strings)
    (insert "\n")))

(provide 'guix-base)

;;; guix-base.el ends here