spacemacs/core/libs/quelpa.el
syl20bnr 1afa0f8faa core: import quelpa and package-build into core/libs
Remove the need to fetch these libs from the internet.
2017-01-23 23:52:57 -05:00

655 lines
24 KiB
EmacsLisp

;;; quelpa.el --- Emacs Lisp packages built directly from source
;; Copyright 2014-2015, Steckerhalter
;; Copyright 2014-2015, Vasilij Schneidermann <v.schneidermann@gmail.com>
;; Author: steckerhalter
;; URL: https://github.com/quelpa/quelpa
;; Version: 0.0.1
;; Package-Requires: ((package-build "0") (emacs "24.3"))
;; Keywords: package management build source elpa
;; 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 file 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 GNU Emacs; see the file COPYING. If not, write to
;; the Free Software Foundation, Inc., 59 Temple Place - Suite 330,
;; Boston, MA 02111-1307, USA.
;;; Commentary:
;; Your personal local Emacs Lisp Package Archive (ELPA) with packages
;; built on-the-fly directly from source.
;; See the README for more info:
;; https://github.com/quelpa/quelpa/blob/master/README.md
;;; Requirements:
;; Emacs 24.3.1
;;; Code:
(require 'package-build)
(require 'cl-lib)
(require 'help-fns)
(require 'url-parse)
;; --- customs / variables ---------------------------------------------------
(defgroup quelpa nil
"Build and install packages from source code"
:group 'package)
(defcustom quelpa-upgrade-p nil
"When non-nil, `quelpa' will try to upgrade packages.
The global value can be overridden for each package by supplying
the `:upgrade' argument."
:group 'quelpa
:type 'boolean)
(defcustom quelpa-stable-p nil
"When non-nil, try to build stable packages like MELPA does."
:group 'quelpa
:type 'boolean)
(defcustom quelpa-verbose t
"When non-nil, `quelpa' prints log messages."
:group 'quelpa
:type 'boolean)
(defcustom quelpa-before-hook nil
"List of functions to be called before quelpa."
:group 'quelpa
:type 'hook)
(defcustom quelpa-after-hook nil
"List of functions to be called after quelpa."
:group 'quelpa
:type 'hook)
(defcustom quelpa-dir (expand-file-name "quelpa" user-emacs-directory)
"Where quelpa builds and stores packages."
:group 'quelpa
:type 'string)
(defcustom quelpa-melpa-dir (expand-file-name "melpa" quelpa-dir)
"Where the melpa repo cloned to."
:group 'quelpa
:type 'string)
(defcustom quelpa-build-dir (expand-file-name "build" quelpa-dir)
"Where quelpa builds packages."
:group 'quelpa
:type 'string)
(defcustom quelpa-packages-dir (expand-file-name "packages" quelpa-dir)
"Where quelpa puts built packages."
:group 'quelpa
:type 'string)
(defcustom quelpa-melpa-recipe-stores (list (expand-file-name
"recipes"
quelpa-melpa-dir))
"Recipe stores where quelpa finds default recipes for packages.
A store can either be a string pointing to a directory with
recipe files or a list with recipes."
:group 'quelpa
:type '(repeat
(choice directory
(repeat
:tag "List of recipes"
(restricted-sexp :tag "Recipe"
:match-alternatives (listp))))))
(defcustom quelpa-persistent-cache-file (expand-file-name "cache" quelpa-dir)
"Location of the persistent cache file."
:group 'quelpa
:type 'string)
(defcustom quelpa-persistent-cache-p t
"Non-nil when quelpa's cache is saved on and read from disk."
:group 'quelpa
:type 'boolean)
(defcustom quelpa-checkout-melpa-p t
"If non-nil the MELPA git repo is cloned when quelpa is initialized."
:group 'quelpa
:type 'boolean)
(defcustom quelpa-update-melpa-p t
"If non-nil the MELPA git repo is updated when quelpa is initialized.
If nil the update is disabled and the repo is only updated on
`quelpa-upgrade' or `quelpa-self-upgrade'."
:group 'quelpa
:type 'boolean)
(defcustom quelpa-melpa-repo-url "https://github.com/melpa/melpa.git"
"The melpa git repository url."
:group 'quelpa
:type 'string)
(defvar quelpa-initialized-p nil
"Non-nil when quelpa has been initialized.")
(defvar quelpa-cache nil
"The `quelpa' command stores processed pkgs/recipes in the cache.")
(defvar quelpa-recipe '(quelpa :repo "quelpa/quelpa" :fetcher github)
"The recipe for quelpa.")
;; --- compatibility for legacy `package.el' in Emacs 24.3 -------------------
(defun quelpa-setup-package-structs ()
"Setup the struct `package-desc' when not available.
`package-desc-from-legacy' is provided to convert the legacy
vector desc into a valid PACKAGE-DESC."
(unless (functionp 'package-desc-p)
(cl-defstruct
(package-desc
(:constructor
;; convert legacy package desc into PACKAGE-DESC
package-desc-from-legacy
(pkg-info kind
&aux
(name (intern (aref pkg-info 0)))
(version (version-to-list (aref pkg-info 3)))
(summary (if (string= (aref pkg-info 2) "")
"No description available."
(aref pkg-info 2)))
(reqs (aref pkg-info 1))
(kind kind))))
name
version
(summary "No description available.")
reqs
kind
archive
dir
extras
signed)))
;; --- package building ------------------------------------------------------
(defun quelpa-package-type (file)
"Determine the package type of FILE.
Return `tar' for tarball packages, `single' for single file
packages, or nil, if FILE is not a package."
(let ((ext (file-name-extension file)))
(cond
((string= ext "tar") 'tar)
((string= ext "el") 'single)
(:else nil))))
(defun quelpa-get-package-desc (file)
"Extract and return the PACKAGE-DESC struct from FILE.
On error return nil."
(let* ((kind (quelpa-package-type file))
(desc (with-demoted-errors "Error getting PACKAGE-DESC: %s"
(with-temp-buffer
(pcase kind
(`single (insert-file-contents file)
(package-buffer-info))
(`tar (insert-file-contents-literally file)
(tar-mode)
(if (help-function-arglist 'package-tar-file-info)
;; legacy `package-tar-file-info' requires an arg
(package-tar-file-info file)
(with-no-warnings (package-tar-file-info)))))))))
(pcase desc
((pred package-desc-p) desc)
((pred vectorp) (package-desc-from-legacy desc kind)))))
(defun quelpa-archive-file-name (archive-entry)
"Return the path of the file in which the package for ARCHIVE-ENTRY is stored."
(let* ((name (car archive-entry))
(pkg-info (cdr archive-entry))
(version (package-version-join (aref pkg-info 0)))
(flavour (aref pkg-info 3)))
(expand-file-name
(format "%s-%s.%s" name version (if (eq flavour 'single) "el" "tar"))
quelpa-packages-dir)))
(defun quelpa-version>-p (name version)
"Return non-nil if VERSION of pkg with NAME is newer than what is currently installed."
(not (or (not version)
(let ((pkg-desc (cdr (assq name package-alist))))
(and pkg-desc
(version-list-<=
(version-to-list version)
(if (functionp 'package-desc-vers)
(package-desc-vers pkg-desc) ;old implementation
(package-desc-version (car pkg-desc))))))
;; Also check built-in packages.
(package-built-in-p name (version-to-list version)))))
(defun quelpa-checkout (rcp dir)
"Return the version of the new package given a RCP.
Return nil if the package is already installed and should not be upgraded."
(pcase-let ((`(,name . ,config) rcp)
(package-build-stable quelpa-stable-p))
(unless (or (and (assq name package-alist) (not quelpa-upgrade-p))
(and (not config)
(quelpa-message t "no recipe found for package `%s'" name)))
(let ((version (condition-case err
(package-build-checkout name config dir)
(error (quelpa-message t
"failed to checkout `%s': `%s'"
name
(error-message-string err))
nil))))
(when (quelpa-version>-p name version)
version)))))
(defun quelpa-build-package (rcp)
"Build a package from the given recipe RCP.
Uses the `package-build' library to get the source code and build
an elpa compatible package in `quelpa-build-dir' storing it in
`quelpa-packages-dir'. Return the path to the created file or nil
if no action is necessary (like when the package is installed
already and should not be upgraded etc)."
(let* ((name (car rcp))
(build-dir (expand-file-name (symbol-name name) quelpa-build-dir))
(version (quelpa-checkout rcp build-dir)))
(when version
(quelpa-archive-file-name
(package-build-package (symbol-name name)
version
(package-build--config-file-list (cdr rcp))
build-dir
quelpa-packages-dir)))))
;; --- package-build.el integration ------------------------------------------
(defun quelpa-file-version (file-path type version time-stamp)
"Return version of file at FILE-PATH."
(if (eq type 'directory)
time-stamp
(cl-letf* ((package-strip-rcs-id-orig (symbol-function 'package-strip-rcs-id))
((symbol-function 'package-strip-rcs-id)
(lambda (str)
(or (funcall package-strip-rcs-id-orig (lm-header "package-version"))
(funcall package-strip-rcs-id-orig (lm-header "version"))
"0"))))
(concat (mapconcat
#'number-to-string
(package-desc-version (quelpa-get-package-desc file-path)) ".")
(pcase version
(`original "")
(_ (concat "pre0." time-stamp)))))))
(defun quelpa-directory-files (path)
"Return list of directory files from PATH recursively."
(let ((result '()))
(mapc
(lambda (file)
(if (file-directory-p file)
(progn
;; When directory is not empty.
(when (cddr (directory-files file))
(dolist (subfile (quelpa-directory-files file))
(add-to-list 'result subfile))))
(add-to-list 'result file)))
(mapcar
(lambda (file) (expand-file-name file path))
;; Without first two entries because they are always "." and "..".
(cddr (directory-files path))))
result))
(defun quelpa-expand-source-file-list (file-path config)
"Return list of source files from FILE-PATH corresponding to
CONFIG."
(let ((source-files
(mapcar
(lambda (file) (expand-file-name file file-path))
(package-build--expand-source-file-list file-path config))))
;; Replace any directories in the source file list with the filenames of the
;; files they contain (so that these files can subsequently be hashed).
(dolist (file source-files source-files)
(when (file-directory-p file)
(setq source-files (remove file source-files))
(setq source-files (append source-files
(quelpa-directory-files file)))))))
(defun quelpa-slurp-file (file)
"Return the contents of FILE as a string, or nil if no such
file exists."
(when (file-exists-p file)
(with-temp-buffer
(set-buffer-multibyte nil)
(setq-local buffer-file-coding-system 'binary)
(insert-file-contents-literally file)
(buffer-substring-no-properties (point-min) (point-max)))))
(defun quelpa-check-hash (name config file-path dir &optional fetcher)
"Check if hash of FILE-PATH is different as in STAMP-FILE.
If it is different save the new hash and timestamp to STAMP-FILE
and return TIME-STAMP, otherwise return OLD-TIME-STAMP."
(unless (file-directory-p dir)
(make-directory dir))
(let* (files
hashes
new-stamp-info
new-content-hash
(time-stamp
(replace-regexp-in-string "\\.0" "." (format-time-string "%Y%m%d.%H%M%S")))
(stamp-file (concat (expand-file-name (symbol-name name) dir) ".stamp"))
(old-stamp-info (package-build--read-from-file stamp-file))
(old-content-hash (cdr old-stamp-info))
(old-time-stamp (car old-stamp-info))
(type (if (file-directory-p file-path) 'directory 'file))
(version (plist-get config :version)))
(if (not (file-exists-p file-path))
(error (quelpa-message t "`%s' does not exist" file-path))
(if (eq type 'directory)
(setq files (quelpa-expand-source-file-list file-path config)
hashes (mapcar
(lambda (file)
(secure-hash
'sha1 (concat file (quelpa-slurp-file file)))) files)
new-content-hash (secure-hash 'sha1 (mapconcat 'identity hashes "")))
(setq new-content-hash (secure-hash 'sha1 (quelpa-slurp-file file-path)))))
(setq new-stamp-info (cons time-stamp new-content-hash))
(if (and old-content-hash
(string= new-content-hash old-content-hash))
(quelpa-file-version file-path type version old-time-stamp)
(unless (eq fetcher 'url)
(delete-directory dir t)
(make-directory dir)
(if (eq type 'file)
(copy-file file-path dir t t t t)
(copy-directory file-path dir t t t)))
(package-build--dump new-stamp-info stamp-file)
(quelpa-file-version file-path type version time-stamp))))
(defun package-build--checkout-file (name config dir)
"Build according to a PATH with config CONFIG into DIR as NAME.
Generic local file handler for package-build.el.
Handles the following cases:
local file:
Installs a single-file package from a local file. Use the :path
attribute with a PATH like \"/path/to/file.el\".
local directory:
Installs a multi-file package from a local directory. Use
the :path attribute with a PATH like \"/path/to/dir\"."
(quelpa-check-hash name config (expand-file-name (plist-get config :path)) dir))
(defun package-build--checkout-url (name config dir)
"Build according to an URL with config CONFIG into DIR as NAME.
Generic URL handler for package-build.el.
Handles the following cases:
local file:
Installs a single-file package from a local file. Use the :url
attribute with an URL like \"file:///path/to/file.el\".
remote file:
Installs a single-file package from a remote file. Use the :url
attribute with an URL like \"http://domain.tld/path/to/file.el\"."
(let* ((url (plist-get config :url))
(remote-file-name (file-name-nondirectory
(url-filename (url-generic-parse-url url))))
(local-path (expand-file-name remote-file-name dir))
(mm-attachment-file-modes (default-file-modes)))
(unless (string= (file-name-extension url) "el")
(error (quelpa-message t "<%s> does not end in .el" url)))
(unless (file-directory-p dir)
(make-directory dir))
(url-copy-file url local-path t)
(quelpa-check-hash name config local-path dir 'url)))
;; --- helpers ---------------------------------------------------------------
(defun quelpa-message (wait format-string &rest args)
"Log a message with FORMAT-STRING and ARGS when `quelpa-verbose' is non-nil.
If WAIT is nil don't wait after showing the message. If it is a
number, wait so many seconds. If WAIT is t wait the default time.
Return t in each case."
(when quelpa-verbose
(message "Quelpa: %s" (apply 'format format-string args))
(when (or (not noninteractive) wait) ; no wait if emacs is noninteractive
(sit-for (or (and (numberp wait) wait) 1.5) t)))
t)
(defun quelpa-read-cache ()
"Read from `quelpa-persistent-cache-file' in `quelpa-cache'."
(when (and quelpa-persistent-cache-p
(file-exists-p quelpa-persistent-cache-file))
(with-temp-buffer
(insert-file-contents-literally quelpa-persistent-cache-file)
(setq quelpa-cache
(read (buffer-substring-no-properties (point-min) (point-max)))))))
(defun quelpa-save-cache ()
"Write `quelpa-cache' to `quelpa-persistent-cache-file'."
(when quelpa-persistent-cache-p
(let (print-level print-length)
(with-temp-file quelpa-persistent-cache-file
(insert (prin1-to-string quelpa-cache))))))
(defun quelpa-update-cache (cache-item)
;; try removing existing recipes by name
(setq quelpa-cache (cl-remove (car cache-item)
quelpa-cache :key #'car))
(push cache-item quelpa-cache)
(setq quelpa-cache
(cl-sort quelpa-cache #'string<
:key (lambda (item) (symbol-name (car item))))))
(defun quelpa-parse-stable (cache-item)
;; in case :stable doesn't originate from PLIST, shadow the
;; default value anyways
(when (plist-member (cdr cache-item) :stable)
(setq quelpa-stable-p (plist-get (cdr cache-item) :stable)))
(when (and quelpa-stable-p (not (plist-get (cdr cache-item) :stable)))
(setf (cdr (last cache-item)) '(:stable t))))
(defun quelpa-checkout-melpa ()
"Fetch or update the melpa source code from Github.
If there is no error return non-nil.
If there is an error but melpa is already checked out return non-nil.
If there is an error and no existing checkout return nil."
(or (and (null quelpa-update-melpa-p)
(file-exists-p (expand-file-name ".git" quelpa-melpa-dir)))
(condition-case err
(package-build--checkout-git
'package-build
`(:url ,quelpa-melpa-repo-url :files ("*"))
quelpa-melpa-dir)
(error (quelpa-message t "failed to checkout melpa git repo: `%s'" (error-message-string err))
(file-exists-p (expand-file-name ".git" quelpa-melpa-dir))))))
(defun quelpa-get-melpa-recipe (name)
"Read recipe with NAME for melpa git checkout.
Return the recipe if it exists, otherwise nil."
(cl-loop for store in quelpa-melpa-recipe-stores
if (stringp store)
for file = (assoc-string name (directory-files store nil "^[^\.]+"))
when file
return (with-temp-buffer
(insert-file-contents-literally
(expand-file-name file store))
(read (buffer-string)))
else
for rcp = (assoc-string name store)
when rcp
return rcp))
(defun quelpa-setup-p ()
"Setup what we need for quelpa.
Return non-nil if quelpa has been initialized properly."
(catch 'quit
(dolist (dir (list quelpa-packages-dir quelpa-build-dir))
(unless (file-exists-p dir) (make-directory dir t)))
(unless quelpa-initialized-p
(quelpa-read-cache)
(quelpa-setup-package-structs)
(if quelpa-checkout-melpa-p
(unless (quelpa-checkout-melpa) (throw 'quit nil)))
(setq quelpa-initialized-p t))
t))
(defun quelpa-shutdown ()
"Do things that need to be done after running quelpa."
(quelpa-save-cache)
;; remove the packages dir because we are done with the built pkgs
(ignore-errors (delete-directory quelpa-packages-dir t)))
(defun quelpa-arg-rcp (arg)
"Given recipe or package name, return an alist '(NAME . RCP).
If RCP cannot be found it will be set to nil"
(pcase arg
(`(,a . nil) (quelpa-get-melpa-recipe (car arg)))
(`(,a . ,_) arg)
((pred symbolp) (quelpa-get-melpa-recipe arg))))
(defun quelpa-parse-plist (plist)
"Parse the optional PLIST argument of `quelpa'.
Recognized keywords are:
:upgrade
If t, `quelpa' tries to do an upgrade.
:stable
If t, `quelpa' tries building the stable version of a package."
(while plist
(let ((key (car plist))
(value (cadr plist)))
(pcase key
(:upgrade (setq quelpa-upgrade-p value))
(:stable (setq quelpa-stable-p value))))
(setq plist (cddr plist))))
(defun quelpa-package-install-file (file)
"Workaround problem with `package-install-file'.
`package-install-file' uses `insert-file-contents-literally'
which causes problems when the file inserted has crlf line
endings (Windows). So here we replace that with
`insert-file-contents' for non-tar files."
(if (eq system-type 'windows-nt)
(cl-letf* ((insert-file-contents-literally-orig
(symbol-function 'insert-file-contents-literally))
((symbol-function 'insert-file-contents-literally)
(lambda (file)
(if (string-match "\\.tar\\'" file)
(funcall insert-file-contents-literally-orig file)
(insert-file-contents file)))))
(package-install-file file))
(package-install-file file)))
(defun quelpa-package-install (arg)
"Build and install package from ARG (a recipe or package name).
If the package has dependencies recursively call this function to
install them."
(let* ((rcp (quelpa-arg-rcp arg))
(file (and rcp (quelpa-build-package rcp))))
(when file
(let* ((pkg-desc (quelpa-get-package-desc file))
(requires (package-desc-reqs pkg-desc)))
(when requires
(mapc (lambda (req)
(unless (equal 'emacs (car req))
(quelpa-package-install (car req))))
requires))
(quelpa-package-install-file file)))))
(defun quelpa-interactive-candidate ()
"Query the user for a recipe and return the name."
(when (quelpa-setup-p)
(let ((recipes (directory-files
(expand-file-name "recipes" quelpa-melpa-dir)
;; this regexp matches all files except dotfiles
nil "^[^.].+$")))
(intern (completing-read "Choose MELPA recipe: "
recipes nil t)))))
;; --- public interface ------------------------------------------------------
;;;###autoload
(defun quelpa-expand-recipe (recipe-name)
"Expand a given recipe name into full recipe.
If called interactively, let the user choose a recipe name and
insert the result into the current buffer."
(interactive (list (quelpa-interactive-candidate)))
(when (quelpa-setup-p)
(let* ((recipe (quelpa-get-melpa-recipe recipe-name)))
(when recipe
(if (called-interactively-p 'any)
(prin1 recipe (current-buffer)))
recipe))))
;;;###autoload
(defun quelpa-self-upgrade (&optional args)
"Upgrade quelpa itself.
ARGS are additional options for the quelpa recipe."
(interactive)
(when (quelpa-setup-p)
(quelpa (append quelpa-recipe args) :upgrade t)))
;;;###autoload
(defun quelpa-upgrade ()
"Upgrade all packages found in `quelpa-cache'.
This provides an easy way to upgrade all the packages for which
the `quelpa' command has been run in the current Emacs session."
(interactive)
(when (quelpa-setup-p)
(let ((quelpa-upgrade-p t))
(quelpa-self-upgrade)
(setq quelpa-cache
(cl-remove-if-not #'package-installed-p quelpa-cache :key #'car))
(mapc (lambda (item)
(when (package-installed-p (car (quelpa-arg-rcp item)))
(quelpa item)))
quelpa-cache))))
;;;###autoload
(defun quelpa (arg &rest plist)
"Build and install a package with quelpa.
ARG can be a package name (symbol) or a melpa recipe (list).
PLIST is a plist that may modify the build and/or fetch process.
If called interactively, `quelpa' will prompt for a MELPA package
to install.
When `quelpa' is called interactively with a prefix argument (e.g
C-u M-x quelpa) it will try to upgrade the given package even if
the global var `quelpa-upgrade-p' is set to nil."
(interactive (list (quelpa-interactive-candidate)))
(run-hooks 'quelpa-before-hook)
(when (quelpa-setup-p) ;if init fails we do nothing
(let* ((quelpa-upgrade-p (if current-prefix-arg t quelpa-upgrade-p)) ;shadow `quelpa-upgrade-p'
(quelpa-stable-p quelpa-stable-p) ;shadow `quelpa-stable-p'
(cache-item (if (symbolp arg) (list arg) arg)))
(quelpa-parse-plist plist)
(quelpa-parse-stable cache-item)
(quelpa-package-install arg)
(quelpa-update-cache cache-item)))
(quelpa-shutdown)
(run-hooks 'quelpa-after-hook))
(provide 'quelpa)
;;; quelpa.el ends here