;;; epl.el --- Emacs Package Library -*- lexical-binding: t; -*-

;; Copyright (C) 2013-2015 Sebastian Wiesner
;; Copyright (C) 1985-1986, 1992, 1994-1995, 1999-2015 Free Software

;; Author: Sebastian Wiesner <swiesner@lunaryorn.com>
;; Maintainer: Johan Andersson <johan.rejeep@gmail.com>
;;     Sebastian Wiesner <swiesner@lunaryorn.com>
;; Version: 0.8
;; Package-Requires: ((cl-lib "0.3"))
;; Keywords: convenience
;; URL: http://github.com/cask/epl

;; This file is NOT part of GNU Emacs.

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

;;; Commentary:

;; A package management library for Emacs, based on package.el.

;; The purpose of this library is to wrap all the quirks and hassle of
;; package.el into a sane API.

;; The following functions comprise the public interface of this library:

;;; Package directory selection

;; `epl-package-dir' gets the directory of packages.

;; `epl-default-package-dir' gets the default package directory.

;; `epl-change-package-dir' changes the directory of packages.

;;; Package system management

;; `epl-initialize' initializes the package system and activates all
;; packages.

;; `epl-reset' resets the package system.

;; `epl-refresh' refreshes all package archives.

;; `epl-add-archive' adds a new package archive.

;;; Package objects

;; Struct `epl-requirement' describes a requirement of a package with `name' and
;; `version' slots.

;; `epl-requirement-version-string' gets a requirement version as string.

;; Struct `epl-package' describes an installed or installable package with a
;; `name' and some internal `description'.

;; `epl-package-version' gets the version of a package.

;; `epl-package-version-string' gets the version of a package as string.

;; `epl-package-summary' gets the summary of a package.

;; `epl-package-requirements' gets the requirements of a package.

;; `epl-package-directory' gets the installation directory of a package.

;; `epl-package-from-buffer' creates a package object for the package contained
;; in the current buffer.

;; `epl-package-from-file' creates a package object for a package file, either
;; plain lisp or tarball.

;; `epl-package-from-descriptor-file' creates a package object for a package
;; description (i.e. *-pkg.el) file.

;;; Package database access

;; `epl-package-installed-p' determines whether a package is installed, either
;; built-in or explicitly installed.

;; `epl-package-outdated-p' determines whether a package is outdated, that is,
;; whether a package with a higher version number is available.

;; `epl-built-in-packages', `epl-installed-packages', `epl-outdated-packages'
;; and `epl-available-packages' get all packages built-in, installed, outdated,
;; or available for installation respectively.

;; `epl-find-built-in-package', `epl-find-installed-packages' and
;; `epl-find-available-packages' find built-in, installed and available packages
;; by name.

;; `epl-find-upgrades' finds all upgradable packages.

;; `epl-built-in-p' return true if package is built-in to Emacs.

;;; Package operations

;; `epl-install-file' installs a package file.

;; `epl-package-install' installs a package.

;; `epl-package-delete' deletes a package.

;; `epl-upgrade' upgrades packages.

;;; Code:

(require 'cl-lib)
(require 'package)


(unless (fboundp #'define-error)
  ;; `define-error' for 24.3 and earlier, copied from subr.el
  (defun define-error (name message &optional parent)
    "Define NAME as a new error signal.
MESSAGE is a string that will be output to the echo area if such an error
is signaled without being caught by a `condition-case'.
PARENT is either a signal or a list of signals from which it inherits.
Defaults to `error'."
    (unless parent (setq parent 'error))
    (let ((conditions
           (if (consp parent)
               (apply #'append
                      (mapcar (lambda (parent)
                                (cons parent
                                      (or (get parent 'error-conditions)
                                          (error "Unknown signal `%s'" parent))))
                              parent))
             (cons parent (get parent 'error-conditions)))))
      (put name 'error-conditions
           (delete-dups (copy-sequence (cons name conditions))))
      (when message (put name 'error-message message)))))

(defsubst epl--package-desc-p (package)
  "Whether PACKAGE is a `package-desc' object.

Like `package-desc-p', but return nil, if `package-desc-p' is not
defined as function."
  (and (fboundp 'package-desc-p) (package-desc-p package)))


;;; EPL errors
(define-error 'epl-error "EPL error")

(define-error 'epl-invalid-package "Invalid EPL package" 'epl-error)

(define-error 'epl-invalid-package-file "Invalid EPL package file"
  'epl-invalid-package)


;;; Package directory
(defun epl-package-dir ()
  "Get the directory of packages."
  package-user-dir)

(defun epl-default-package-dir ()
  "Get the default directory of packages."
  (eval (car (get 'package-user-dir 'standard-value))))

(defun epl-change-package-dir (directory)
  "Change the directory of packages to DIRECTORY."
  (setq package-user-dir directory)
  (epl-initialize))


;;; Package system management
(defvar epl--load-path-before-initialize nil
  "Remember the load path for `epl-reset'.")

(defun epl-initialize (&optional no-activate)
  "Load Emacs Lisp packages and activate them.

With NO-ACTIVATE non-nil, do not activate packages."
  (setq epl--load-path-before-initialize load-path)
  (package-initialize no-activate))

(defalias 'epl-refresh 'package-refresh-contents)

(defun epl-add-archive (name url)
  "Add a package archive with NAME and URL."
  (add-to-list 'package-archives (cons name url)))

(defun epl-reset ()
  "Reset the package system.

Clear the list of installed and available packages, the list of
package archives and reset the package directory."
  (setq package-alist nil
        package-archives nil
        package-archive-contents nil
        load-path epl--load-path-before-initialize)
  (when (boundp 'package-obsolete-alist) ; Legacy package.el
    (setq package-obsolete-alist nil))
  (epl-change-package-dir (epl-default-package-dir)))


;;; Package structures
(cl-defstruct (epl-requirement
               (:constructor epl-requirement-create))
  "Structure describing a requirement.

Slots:

`name' The name of the required package, as symbol.

`version' The version of the required package, as version list."
  name
  version)

(defun epl-requirement-version-string (requirement)
  "The version of a REQUIREMENT, as string."
  (package-version-join (epl-requirement-version requirement)))

(cl-defstruct (epl-package (:constructor epl-package-create))
  "Structure representing a package.

Slots:

`name' The package name, as symbol.

`description' The package description.

The format package description varies between package.el
variants.  For `package-desc' variants, it is simply the
corresponding `package-desc' object.  For legacy variants, it is
a vector `[VERSION REQS DOCSTRING]'.

Do not access `description' directly, but instead use the
`epl-package' accessors."
  name
  description)

(defmacro epl-package-as-description (var &rest body)
  "Cast VAR to a package description in BODY.

VAR is a symbol, bound to an `epl-package' object.  This macro
casts this object to the `description' object, and binds the
description to VAR in BODY."
  (declare (indent 1))
  (unless (symbolp var)
    (signal 'wrong-type-argument (list #'symbolp var)))
  `(if (epl-package-p ,var)
       (let ((,var (epl-package-description ,var)))
         ,@body)
     (signal 'wrong-type-argument (list #'epl-package-p ,var))))

(defsubst epl-package--package-desc-p (package)
  "Whether the description of PACKAGE is a `package-desc'."
  (epl--package-desc-p (epl-package-description package)))

(defun epl-package-version (package)
  "Get the version of PACKAGE, as version list."
  (epl-package-as-description package
    (cond
     ((fboundp 'package-desc-version) (package-desc-version package))
     ;; Legacy
     ((fboundp 'package-desc-vers)
      (let ((version (package-desc-vers package)))
        (if (listp version) version (version-to-list version))))
     (:else (error "Cannot get version from %S" package)))))

(defun epl-package-version-string (package)
  "Get the version from a PACKAGE, as string."
  (package-version-join (epl-package-version package)))

(defun epl-package-summary (package)
  "Get the summary of PACKAGE, as string."
  (epl-package-as-description package
    (cond
     ((fboundp 'package-desc-summary) (package-desc-summary package))
     ((fboundp 'package-desc-doc) (package-desc-doc package)) ; Legacy
     (:else (error "Cannot get summary from %S" package)))))

(defsubst epl-requirement--from-req (req)
  "Create a `epl-requirement' from a `package-desc' REQ."
  (let  ((version (cadr req)))
    (epl-requirement-create :name (car req)
                            :version (if (listp version) version
                                       (version-to-list version)))))

(defun epl-package-requirements (package)
  "Get the requirements of PACKAGE.

The requirements are a list of `epl-requirement' objects."
  (epl-package-as-description package
    (mapcar #'epl-requirement--from-req (package-desc-reqs package))))

(defun epl-package-directory (package)
  "Get the directory PACKAGE is installed to.

Return the absolute path of the installation directory of
PACKAGE, or nil, if PACKAGE is not installed."
  (cond
   ((fboundp 'package-desc-dir)
    (package-desc-dir (epl-package-description package)))
   ((fboundp 'package--dir)
    (package--dir (symbol-name (epl-package-name package))
                  (epl-package-version-string package)))
   (:else (error "Cannot get package directory from %S" package))))

(defun epl-package-->= (pkg1 pkg2)
  "Determine whether PKG1 is before PKG2 by version."
  (not (version-list-< (epl-package-version pkg1)
                       (epl-package-version pkg2))))

(defun epl-package--from-package-desc (package-desc)
  "Create an `epl-package' from a PACKAGE-DESC.

PACKAGE-DESC is a `package-desc' object, from recent package.el
variants."
  (if (and (fboundp 'package-desc-name)
           (epl--package-desc-p package-desc))
      (epl-package-create :name (package-desc-name package-desc)
                          :description package-desc)
    (signal 'wrong-type-argument (list 'epl--package-desc-p package-desc))))

(defun epl-package--parse-info (info)
  "Parse a package.el INFO."
  (if (epl--package-desc-p info)
      (epl-package--from-package-desc info)
    ;; For legacy package.el, info is a vector [NAME REQUIRES DESCRIPTION
    ;; VERSION COMMENTARY].  We need to re-shape this vector into the
    ;; `package-alist' format [VERSION REQUIRES DESCRIPTION] to attach it to the
    ;; new `epl-package'.
    (let ((name (intern (aref info 0)))
          (info (vector (aref info 3) (aref info 1) (aref info 2))))
      (epl-package-create :name name :description info))))

(defun epl-package-from-buffer (&optional buffer)
  "Create an `epl-package' object from BUFFER.

BUFFER defaults to the current buffer.

Signal `epl-invalid-package' if the buffer does not contain a
valid package file."
  (let ((info (with-current-buffer (or buffer (current-buffer))
                (condition-case err
                    (package-buffer-info)
                  (error (signal 'epl-invalid-package (cdr err)))))))
    (epl-package--parse-info info)))

(defun epl-package-from-lisp-file (file-name)
  "Parse the package headers the file at FILE-NAME.

Return an `epl-package' object with the header metadata."
  (with-temp-buffer
    (insert-file-contents file-name)
    (condition-case err
        (epl-package-from-buffer (current-buffer))
      ;; Attach file names to invalid package errors
      (epl-invalid-package
       (signal 'epl-invalid-package-file (cons file-name (cdr err))))
      ;; Forward other errors
      (error (signal (car err) (cdr err))))))

(defun epl-package-from-tar-file (file-name)
  "Parse the package tarball at FILE-NAME.

Return a `epl-package' object with the meta data of the tarball
package in FILE-NAME."
  (condition-case nil
      ;; In legacy package.el, `package-tar-file-info' takes the name of the tar
      ;; file to parse as argument.  In modern package.el, it has no arguments
      ;; and works on the current buffer.  Hence, we just try to call the legacy
      ;; version, and if that fails because of a mismatch between formal and
      ;; actual arguments, we use the modern approach.  To avoid spurious
      ;; signature warnings by the byte compiler, we suppress warnings when
      ;; calling the function.
      (epl-package--parse-info (with-no-warnings
                                 (package-tar-file-info file-name)))
    (wrong-number-of-arguments
     (with-temp-buffer
       (insert-file-contents-literally file-name)
       ;; Switch to `tar-mode' to enable extraction of the file.  Modern
       ;; `package-tar-file-info' relies on `tar-mode', and signals an error if
       ;; called in a buffer with a different mode.
       (tar-mode)
       (epl-package--parse-info (with-no-warnings
                                  (package-tar-file-info)))))))

(defun epl-package-from-file (file-name)
  "Parse the package at FILE-NAME.

Return an `epl-package' object with the meta data of the package
at FILE-NAME."
  (if (string-match-p (rx ".tar" string-end) file-name)
      (epl-package-from-tar-file file-name)
    (epl-package-from-lisp-file file-name)))

(defun epl-package--parse-descriptor-requirement (requirement)
  "Parse a REQUIREMENT in a package descriptor."
  ;; This function is only called on legacy package.el.  On package-desc
  ;; package.el, we just let package.el do the work.
  (cl-destructuring-bind (name version-string) requirement
    (list name (version-to-list version-string))))

(defun epl-package-from-descriptor-file (descriptor-file)
  "Load a `epl-package' from a package DESCRIPTOR-FILE.

A package descriptor is a file defining a new package.  Its name
typically ends with -pkg.el."
  (with-temp-buffer
    (insert-file-contents descriptor-file)
    (goto-char (point-min))
    (let ((sexp (read (current-buffer))))
      (unless (eq (car sexp) 'define-package)
        (error "%S is no valid package descriptor" descriptor-file))
      (if (and (fboundp 'package-desc-from-define)
               (fboundp 'package-desc-name))
          ;; In Emacs snapshot, we can conveniently call a function to parse the
          ;; descriptor
          (let ((desc (apply #'package-desc-from-define (cdr sexp))))
            (epl-package-create :name (package-desc-name desc)
                                :description desc))
        ;; In legacy package.el, we must manually deconstruct the descriptor,
        ;; because the load function has eval's the descriptor and has a lot of
        ;; global side-effects.
        (cl-destructuring-bind
            (name version-string summary requirements) (cdr sexp)
          (epl-package-create
           :name (intern name)
           :description
           (vector (version-to-list version-string)
                   (mapcar #'epl-package--parse-descriptor-requirement
                           ;; Strip the leading `quote' from the package list
                           (cadr requirements))
                   summary)))))))


;;; Package database access
(defun epl-package-installed-p (package)
  "Determine whether a PACKAGE is installed.

PACKAGE is either a package name as symbol, or a package object."
  (let ((name (if (epl-package-p package)
                  (epl-package-name package)
                package))
        (version (when (epl-package-p package)
                   (epl-package-version package))))
    (package-installed-p name version)))

(defun epl--parse-built-in-entry (entry)
  "Parse an ENTRY from the list of built-in packages.

Return the corresponding `epl-package' object."
  (if (fboundp 'package--from-builtin)
      ;; In package-desc package.el, convert the built-in package to a
      ;; `package-desc' and convert that to an `epl-package'
      (epl-package--from-package-desc (package--from-builtin entry))
    (epl-package-create :name (car entry) :description (cdr entry))))

(defun epl-built-in-packages ()
  "Get all built-in packages.

Return a list of `epl-package' objects."
  ;; This looks mighty strange, but it's the only way to force package.el to
  ;; build the list of built-in packages.  Without this, `package--builtins'
  ;; might be empty.
  (package-built-in-p 'foo)
  (mapcar #'epl--parse-built-in-entry package--builtins))

(defun epl-find-built-in-package (name)
  "Find a built-in package with NAME.

NAME is a package name, as symbol.

Return the built-in package as `epl-package' object, or nil if
there is no built-in package with NAME."
  (when (package-built-in-p name)
    ;; We must call `package-built-in-p' *before* inspecting
    ;; `package--builtins', because otherwise `package--builtins' might be
    ;; empty.
    (epl--parse-built-in-entry (assq name package--builtins))))

(defun epl-package-outdated-p (package)
  "Determine whether a PACKAGE is outdated.

A package is outdated, if there is an available package with a
higher version.

PACKAGE is either a package name as symbol, or a package object.
In the former case, test the installed or built-in package with
the highest version number, in the later case, test the package
object itself.

Return t, if the package is outdated, or nil otherwise."
  (let* ((package (if (epl-package-p package)
                      package
                    (or (car (epl-find-installed-packages package))
                        (epl-find-built-in-package package))))
         (available (car (epl-find-available-packages
                          (epl-package-name package)))))
    (and package available (version-list-< (epl-package-version package)
                                           (epl-package-version available)))))

(defun epl--parse-package-list-entry (entry)
  "Parse a list of packages from ENTRY.

ENTRY is a single entry in a package list, e.g. `package-alist',
`package-archive-contents', etc.  Typically it is a cons cell,
but the exact format varies between package.el versions.  This
function tries to parse all known variants.

Return a list of `epl-package' objects parsed from ENTRY."
  (let ((descriptions (cdr entry)))
    (cond
     ((listp descriptions)
      (sort (mapcar #'epl-package--from-package-desc descriptions)
            #'epl-package-->=))
     ;; Legacy package.el has just a single package in an entry, which is a
     ;; standard description vector
     ((vectorp descriptions)
      (list (epl-package-create :name (car entry)
                                :description descriptions)))
     (:else (error "Cannot parse entry %S" entry)))))

(defun epl-installed-packages ()
  "Get all installed packages.

Return a list of package objects."
  (apply #'append (mapcar #'epl--parse-package-list-entry package-alist)))

(defsubst epl--filter-outdated-packages (packages)
  "Filter outdated packages from PACKAGES."
  (let (res)
    (dolist (package packages)
      (when (epl-package-outdated-p package)
        (push package res)))
    (nreverse res)))

(defun epl-outdated-packages ()
  "Get all outdated packages, as in `epl-package-outdated-p'.

Return a list of package objects."
  (epl--filter-outdated-packages (epl-installed-packages)))

(defsubst epl--find-package-in-list (name list)
  "Find a package by NAME in a package LIST.

Return a list of corresponding `epl-package' objects."
  (let ((entry (assq name list)))
    (when entry
      (epl--parse-package-list-entry entry))))

(defun epl-find-installed-package (name)
  "Find the latest installed package by NAME.

NAME is a package name, as symbol.

Return the installed package with the highest version number as
`epl-package' object, or nil, if no package with NAME is
installed."
  (car (epl-find-installed-packages name)))
(make-obsolete 'epl-find-installed-package 'epl-find-installed-packages "0.7")

(defun epl-find-installed-packages (name)
  "Find all installed packages by NAME.

NAME is a package name, as symbol.

Return a list of all installed packages with NAME, sorted by
version number in descending order.  Return nil, if there are no
packages with NAME."
  (epl--find-package-in-list name package-alist))

(defun epl-available-packages ()
  "Get all packages available for installation.

Return a list of package objects."
  (apply #'append (mapcar #'epl--parse-package-list-entry
                          package-archive-contents)))

(defun epl-find-available-packages (name)
  "Find available packages for NAME.

NAME is a package name, as symbol.

Return a list of available packages for NAME, sorted by version
number in descending order.  Return nil, if there are no packages
for NAME."
  (epl--find-package-in-list name package-archive-contents))

(cl-defstruct (epl-upgrade
               (:constructor epl-upgrade-create))
  "Structure describing an upgradable package.
Slots:

`installed' The installed package

`available' The package available for installation."
  installed
  available)

(defun epl-find-upgrades (&optional packages)
  "Find all upgradable PACKAGES.

PACKAGES is a list of package objects to upgrade, defaulting to
all installed packages.

Return a list of `epl-upgrade' objects describing all upgradable
packages."
  (let ((packages (or packages (epl-installed-packages)))
        upgrades)
    (dolist (pkg packages)
      (let* ((version (epl-package-version pkg))
             (name (epl-package-name pkg))
             ;; Find the latest available package for NAME
             (available-pkg (car (epl-find-available-packages name)))
             (available-version (when available-pkg
                                  (epl-package-version available-pkg))))
        (when (and available-version (version-list-< version available-version))
          (push (epl-upgrade-create :installed pkg
                                    :available available-pkg)
                upgrades))))
    (nreverse upgrades)))

(defalias 'epl-built-in-p 'package-built-in-p)


;;; Package operations

(defalias 'epl-install-file 'package-install-file)

(defun epl-package-install (package &optional force)
  "Install a PACKAGE.

PACKAGE is a `epl-package' object.  If FORCE is given and
non-nil, install PACKAGE, even if it is already installed."
  (when (or force (not (epl-package-installed-p package)))
    (if (epl-package--package-desc-p package)
        (package-install (epl-package-description package))
      ;; The legacy API installs by name.  We have no control over versioning,
      ;; etc.
      (package-install (epl-package-name package)))))

(defun epl-package-delete (package)
  "Delete a PACKAGE.

PACKAGE is a `epl-package' object to delete."
  ;; package-delete allows for packages being trashed instead of fully deleted.
  ;; Let's prevent his silly behavior
  (let ((delete-by-moving-to-trash nil))
    ;; The byte compiler will warn us that we are calling `package-delete' with
    ;; the wrong number of arguments, since it can't infer that we guarantee to
    ;; always call the correct version.  Thus we suppress all warnings when
    ;; calling `package-delete'.  I wish there was a more granular way to
    ;; disable just that specific warning, but it is what it is.
    (if (epl-package--package-desc-p package)
        (with-no-warnings
          (package-delete (epl-package-description package)))
      ;; The legacy API deletes by name (as string!) and version instead by
      ;; descriptor.  Hence `package-delete' takes two arguments.  For some
      ;; insane reason, the arguments are strings here!
      (let ((name (symbol-name (epl-package-name package)))
            (version (epl-package-version-string package)))
        (with-no-warnings
          (package-delete name version))
        ;; Legacy package.el does not remove the deleted package
        ;; from the `package-alist', so we do it manually here.
        (let ((pkg (assq (epl-package-name package) package-alist)))
          (when pkg
            (setq package-alist (delq pkg package-alist))))))))

(defun epl-upgrade (&optional packages preserve-obsolete)
  "Upgrade PACKAGES.

PACKAGES is a list of package objects to upgrade, defaulting to
all installed packages.

The old versions of the updated packages are deleted, unless
PRESERVE-OBSOLETE is non-nil.

Return a list of all performed upgrades, as a list of
`epl-upgrade' objects."
  (let ((upgrades (epl-find-upgrades packages)))
    (dolist (upgrade upgrades)
      (epl-package-install (epl-upgrade-available upgrade) 'force)
      (unless preserve-obsolete
        (epl-package-delete (epl-upgrade-installed upgrade))))
    upgrades))

(provide 'epl)

;;; epl.el ends here
