;;; skewer-bower.el --- dynamic library loading -*- lexical-binding: t; -*-

;; This is free and unencumbered software released into the public domain.

;;; Commentary:

;; This package loads libraries into the current page using the bower
;; infrastructure. Note: bower is not actually used by this package
;; and so does *not* need to be installed. Only git is required (see
;; `skewer-bower-git-executable'). It will try to learn how to run git
;; from Magit if available.

;; The interactive command for loading libraries is
;; `skewer-bower-load'. It will prompt for a library and a version,
;; automatically fetching it from the bower infrastructure if needed.
;; For example, I often find it handy to load some version of jQuery
;; when poking around at a page that doesn't already have it loaded.

;; Caveat: unfortunately the bower infrastructure is a mess; many
;; packages are in some sort of broken state -- missing dependencies,
;; missing metadata, broken metadata, or an invalid repository URL.
;; Some of this is due to under-specification of the metadata by the
;; bower project. Broken packages are unlikely to be loadable by
;; skewer-bower.

;;; Code:

(require 'cl-lib)
(require 'skewer-mode)
(require 'simple-httpd)
(require 'magit nil t) ; optional

(defcustom skewer-bower-cache-dir (locate-user-emacs-file "skewer-cache")
  "Location of library cache (git repositories)."
  :type 'string
  :group 'skewer)

(defcustom skewer-bower-endpoint "https://bower.herokuapp.com"
  "Endpoint for accessing package information."
  :type 'string
  :group 'skewer)

(defcustom skewer-bower-json '("bower.json" "package.json" "component.json")
  "Files to search for package metadata."
  :type 'list
  :group 'skewer)

; Try to match Magit's configuration if available
(defcustom skewer-bower-git-executable "git"
  "Name of the git executable."
  :type 'string
  :group 'skewer)

(defvar skewer-bower-packages nil
  "Alist of all packages known to bower.")

(defvar skewer-bower-refreshed nil
  "List of packages that have been refreshed recently. This keeps
them from hitting the network frequently.")

;;;###autoload
(defun skewer-bower-refresh ()
  "Update the package listing and packages synchronously."
  (interactive)
  (cl-declare (special url-http-end-of-headers))
  (setf skewer-bower-refreshed nil)
  (with-current-buffer
      (url-retrieve-synchronously (concat skewer-bower-endpoint "/packages"))
    (setf (point) url-http-end-of-headers)
    (setf skewer-bower-packages
          (cl-sort
           (cl-loop for package across (json-read)
                    collect (cons (cdr (assoc 'name package))
                                  (cdr (assoc 'url package))))
           #'string< :key #'car))))

;; Git functions

(defun skewer-bower-cache (package)
  "Return the cache repository directory for PACKAGE."
  (unless (file-exists-p skewer-bower-cache-dir)
    (make-directory skewer-bower-cache-dir t))
  (expand-file-name package skewer-bower-cache-dir))

(defun skewer-bower-git (package &rest args)
  "Run git for PACKAGE's repository with ARGS."
  (with-temp-buffer
    (when (zerop (apply #'call-process skewer-bower-git-executable nil t nil
                        (format "--git-dir=%s" (skewer-bower-cache package))
                        args))
      (buffer-string))))

(defun skewer-bower-git-clone (url package)
  "Clone or fetch PACKAGE's repository from URL if needed."
  (if (member package skewer-bower-refreshed)
      t
    (let* ((cache (skewer-bower-cache package))
           (status
            (if (file-exists-p cache)
                (when (skewer-bower-git package "fetch")
                  (push package skewer-bower-refreshed))
              (skewer-bower-git package "clone" "--bare" url cache))))
      (not (null status)))))

(defun skewer-bower-git-show (package version file)
  "Grab FILE from PACKAGE at version VERSION."
  (when (string-match-p "^\\./" file) ; avoid relative paths
    (setf file (substring file 2)))
  (skewer-bower-git package "show" (format "%s:%s" version file)))

(defun skewer-bower-git-tag (package)
  "List all the tags in PACKAGE's repository."
  (split-string (skewer-bower-git package "tag")))

;; Bower functions

(defun skewer-bower-package-ensure (package)
  "Ensure a package is installed in the cache and up to date.
Emit an error if the package could not be ensured."
  (when (null skewer-bower-packages) (skewer-bower-refresh))
  (let ((url (cdr (assoc package skewer-bower-packages))))
    (when (null url)
      (error "Unknown package: %s" package))
    (when (null (skewer-bower-git-clone url package))
      (error "Failed to fetch: %s" url))
    t))

(defun skewer-bower-package-versions (package)
  "List the available versions for a package. Always returns at
least one version."
  (skewer-bower-package-ensure package)
  (or (sort (skewer-bower-git-tag package) #'string<)
      (list "master")))

(defun skewer-bower-get-config (package &optional version)
  "Get the configuration alist for PACKAGE at VERSION. Return nil
if no configuration could be found."
  (skewer-bower-package-ensure package)
  (unless version (setf version "master"))
  (json-read-from-string
   (cl-loop for file in skewer-bower-json
            for config = (skewer-bower-git-show package version file)
            when config return it
            finally (return "null"))))

;; Serving the library

(defvar skewer-bower-history ()
  "Library selection history for `completing-read'.")

(defun skewer-bowser--path (package version main)
  "Return the simple-httpd hosted path for PACKAGE."
  (format "/skewer/bower/%s/%s/%s" package (or version "master") main))

(defun skewer-bower-prompt-package ()
  "Prompt for a package and version from the user."
  (when (null skewer-bower-packages) (skewer-bower-refresh))
  ;; ido-completing-read bug workaround:
  (when (> (length skewer-bower-history) 32)
    (setf skewer-bower-history (cl-subseq skewer-bower-history 0 16)))
  (let* ((packages (mapcar #'car skewer-bower-packages))
         (selection (nconc skewer-bower-history packages))
         (package (completing-read "Library: " selection nil t nil
                                   'skewer-bower-history))
         (versions (reverse (skewer-bower-package-versions package)))
         (version (completing-read "Version: " versions
                                   nil t nil nil (car versions))))
    (list package version)))

(defun skewer-bower--js-p (filename)
  "Return non-nil if FILENAME looks like JavaScript."
  (string-match "\\.js$" filename))

(defun skewer-bower-guess-main (package version config)
  "Attempt to determine the main entrypoints from a potentially
incomplete or incorrect bower configuration. Returns nil if
guessing failed."
  (let ((check (apply-partially #'skewer-bower-git-show package version))
        (main (cdr (assoc 'main config))))
    (cond ((and (vectorp main) (cl-some check main))
           (cl-coerce (cl-remove-if-not #'skewer-bower--js-p main) 'list))
          ((and (stringp main) (funcall check main))
           (list main))
          ((funcall check (concat package ".js"))
           (list (concat package ".js")))
          ((funcall check package)
           (list package)))))

;;;###autoload
(defun skewer-bower-load (package &optional version)
  "Dynamically load a library from bower into the current page."
  (interactive (skewer-bower-prompt-package))
  (let* ((config (skewer-bower-get-config package version))
         (deps (cdr (assoc 'dependencies config)))
         (main (skewer-bower-guess-main package version config)))
    (when (null main)
      (error "Could not load %s (%s): no \"main\" entrypoint specified"
             package version))
    (cl-loop for (dep . version) in deps
             do (skewer-bower-load (format "%s" dep) version))
    (cl-loop for entrypoint in main
             for path = (skewer-bowser--path package version entrypoint)
             do (skewer-eval path nil :type "script"))))

(defservlet skewer/bower "application/javascript; charset=utf-8" (path)
  "Serve a script from the local bower repository cache."
  (cl-destructuring-bind (_ _skewer _bower package version . parts)
      (split-string path "/")
    (let* ((file (mapconcat #'identity parts "/"))
           (contents (skewer-bower-git-show package version file)))
      (if contents
          (insert contents)
        (httpd-error t 404)))))

(provide 'skewer-bower)

;;; skewer-bower.el ends here