Find file
Fetching contributors…
Cannot retrieve contributors at this time
449 lines (402 sloc) 14.9 KB
;;; make-it-so.el --- Transform files with Makefile recipes. -*- lexical-binding: t -*-
;; Copyright (C) 2014 Oleh Krehel
;; Author: Oleh Krehel <>
;; URL:
;; Version: 0.1.0
;; Package-Requires: ((swiper "0.8.0") (emacs "24"))
;; Keywords: make, dired
;; 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
;; GNU General Public License for more details.
;; For a full copy of the GNU General Public License
;; see <>.
;;; Commentary:
;; This package is aimed on minimizing the effort of interacting with
;; the command tools involved in transforming files.
;; For instance, once in a blue moon you might need to transform a
;; large flac file based on a cue file into smaller flac files, or mp3
;; files for that matter.
;; Case 1: if you're doing it for the first time, you'll search the
;; internet for a command tool that does this job, and for particular
;; switches.
;; Case 2: you've done it before. The problem is that when you want to
;; do the transform for the second time, you have likely forgotten all
;; the command switches and maybe even the command itself.
;; The solution is to write the command to a Makefile when you find it
;; the first time around. This particular Makefile you should save to
;; ./recipes/cue/split/Makefile.
;; Case 3: you have a Makefile recipe. Just navigate to the dispatch
;; file (*.cue in this case) with `dired' and press "," which is bound
;; to `make-it-so'. You'll be prompted to select a transformation
;; recipe that applies to *.cue files. Select "split". The following
;; steps are:
;; 1. A staging directory will be created in place of the input
;; files and they will be moved there.
;; 2. Your selected Makefile template will be copied to the staging
;; directory and opened for you to tweak the parameters.
;; 3. When you're done, call `compile' to make the transformation.
;; It's bound to [f5] in `make-mode' by this package.
;; 4. If you want to cancel at this point, discarding the results of
;; the transformation (which is completely safe, since they can be
;; regenerated), call `mis-abort', bound to "C-M-,".
;; 5. If you want to keep both the input and output files, call
;; `mis-finalize', bound to "C-,".
;; 6. If you want to keep only the output files, call `mis-replace',
;; bound to "C-M-.". The original files will be moved to trash.
;; 7. Finally, consider contributing Makefile recipes to allow
;; other users to skip Case 1 and Case 2.
;;; Code:
;;* Requires
(require 'dired)
(require 'make-mode)
(require 'cl-lib)
;;* Customization
(defgroup make-it-so nil
"Transfrom files in `dired' with Makefile recipes."
:group 'dired
:prefix "mis-")
(defcustom mis-completion-method 'ivy
"Method to select a candidate from a list of strings."
:type '(choice
(const :tag "Ivy" ivy)
(const :tag "Helm" helm)))
(defcustom mis-recipes-directory "~/git/make-it-so/recipes/"
"Directory with available recipes."
:type 'directory
:group 'make-it-so)
(defcustom mis-bindings-alist
'((make-it-so . ",")
(mis-finalize . "C-,")
(mis-abort . "C-M-,")
(mis-dispatch . "C-.")
(mis-replace . "C-M-."))
"List of bindings for the minor mode."
:group 'make-it-so)
(defcustom mis-make-command "make -j8"
"Customize the make command bound to `mis-make-key'.
Option -j8 will allow up to 8 asynchronous processes to make the targets."
:group 'make-it-so)
(defcustom mis-make-key "<f5>"
"`mis-make-command' will be bound to this key in `makefile-mode'."
:group 'make-it-so)
;;* Setup
(defvar mis-mode-map
(let ((map mis-mode-map))
(mapc (lambda (x) (define-key map (kbd (cdr x)) (car x)))
(defvar mis-current-files nil
"Current set of files to transform.")
(define-minor-mode mis-mode
"Add make-it-so key bindings to `dired'.
:keymap mis-mode-map
:group 'make-it-so)
(defun mis-mode-on ()
"Enable `make-it-so' bindings."
(mis-mode 1))
(defun mis-config-default ()
"Easy config."
(add-hook 'dired-mode-hook 'mis-mode-on)
(when mis-make-key
(define-key makefile-mode-map (kbd mis-make-key) 'mis-save-and-compile)))
(defun mis-competing-read (prompt collection action)
(if (eq mis-completion-method 'helm)
(require 'helm)
(require 'helm-help)
(helm :sources
`((name . ,prompt)
(candidates . ,collection)
(action . ,action))))
(require 'ivy)
(ivy-read prompt collection
:action action)))
;;* Interactive
(defun mis-browse ()
"List all available recipes.
Jump to the Makefile of the selected recipe."
(lambda (x)
(if (string-match "^\\([^-]+\\)-\\(.*\\)$" x)
(match-string 1 x)
(match-string 2 x)
(error "Failed to split %s" x)))))
(defun mis-create-makefile (action)
"Create a new Makefile for ACTION."
(let* ((olde (file-name-extension (car mis-current-files)))
(newe (if (string-match "^to-\\(.*\\)" action)
(match-string 1 action)
(concat "out." olde)))
(preamble (concat "# This is a template for the Makefile.\n"
"# Parameters should go in the upper half as:\n"
"# width = 200\n"
"# and be referenced in the command as $(width)\n\n"
"# " (make-string 78 ?_)))
(olds (format "DIR%s = $(shell dir *.%s)" (upcase olde) olde))
(news (format "DIR%s = $(DIR%s:.%s=.%s)"
(upcase newe) (upcase olde) olde newe))
(t-all (format "all: clean Makefile $(DIR%s)" (upcase newe)))
(t-new (concat
(format "%%.%s: %%.%s\n\techo \"add command here\"" newe olde)
"\n\techo $@ >> provide"))
(t-clean (format "clean:\n\trm -f *.%s provide" newe))
(t-tools (concat
"# Insert the install command here.\n"
"# e.g. sudo apt-get install ffmpeg\n"
"echo \"No tools required\""))
(t-req (concat
"# Use this target when one file requires another.\n"
"# See \"../../cue/split/Makefile\" for an example.\n"
(t-phony ".PHONY: all install-tools require clean")
(Makefile (mapconcat 'identity
(list preamble olds news t-all t-new
t-clean t-tools t-req t-phony)
(mis-spit Makefile
(defun make-it-so ()
"When called from `dired', offer a list of transformations.
Available trasformations are dispatched on currently selected
file(s)' extension. Therefore it's an error when files with
multiple extensions are marked. After an action is selected,
proceed to call `mis-action' for that action."
(cl-case major-mode
(call-interactively 'self-insert-command))
(if (mis-all-equal
(setq mis-current-files
(dired-get-marked-files nil current-prefix-arg))))
(let* ((ext (file-name-extension (car mis-current-files)))
(candidates (mis-recipes-by-ext ext)))
"Makefile: " candidates 'mis-action))
(error "Mixed extensions in selection")))
(error "Must be called from dired"))))
(defun mis-abort ()
"Abort tranformation.
This function should revert to the state before `mis-action' was called."
(unless (file-exists-p "Makefile")
(error "No Makefile in current directory"))
(let ((makefile-buffer (find-buffer-visiting (expand-file-name "Makefile")))
(dired-buffer (current-buffer))
(dir default-directory)
(targets (read (mis-slurp "targets")))
(sources (read (mis-slurp "sources"))))
(when makefile-buffer
(kill-buffer makefile-buffer))
(cl-mapcar 'rename-file targets sources)
(dired "..")
(kill-buffer dired-buffer)
(delete-directory dir t)
(defun mis-finalize ()
"Finalize transformation.
In addition to `mis-abort' copy over the files listed in
\"provide\". Each Makefile should append all essential files
that it creates to a \"provide\" file. All generated files not in \"provide\",
i.e. intermediates and logs and such, will be deleted."
(unless (file-exists-p "Makefile")
(error "No Makefile in current directory"))
(unless (file-exists-p "provide")
(error "No provide in current directory"))
(let ((provides (split-string (mis-slurp "provide") "\n" t)))
(mapc (lambda (f) (mis-rename-unquote f (expand-file-name ".."))) provides)
(defun mis-replace ()
"Finalize transformation.
In addition to `mis-finalize' move source files to trash."
(let ((sources (read (mis-slurp "sources"))))
(mapc 'mis-delete-file sources))
(defun mis-dispatch ()
"Choose \"mis-\" via completion."
"Action: "
(lambda (x)
(format "% -30s%s" x
(or (cdr (assoc x mis-bindings-alist))
"not bound"))
'(mis-finalize mis-abort mis-replace))
(lambda (x) (call-interactively (car x)))))
(defun mis-save-and-compile ()
"Save current buffer and call `compile' with `mis-make-command'.
Switch to other window afterwards."
(compile mis-make-command))
;;* Utilities
(defun mis-directory-files (directory)
"Return results of (`directory-files' DIRECTORY) without \".\" and \"..\"."
(and (file-exists-p directory)
(delete "." (delete ".." (directory-files directory)))))
(defun mis-recipes-by-ext (ext)
"Return a list of recipes available for EXT."
(setq ext (or ext "nil"))
(expand-file-name ext (mis-directory))))
(defun mis-recipes ()
"Return a list of current recipes."
(let ((formats (mis-directory-files (mis-directory))))
(apply #'append
(cl-loop for f in formats
(mapcar (lambda (x) (format "%s-%s" f x))
(mis-recipes-by-ext f))))))
(defun mis-build-path (&rest lst)
"Build a path from LST."
(cl-reduce (lambda (a b) (expand-file-name b a)) lst))
(defun mis-build-path-create (&rest lst)
"Build a path from LST. Create intermediate directories."
(car (last lst))
(lambda (a b)
(let ((dir (expand-file-name b a)))
(unless (file-exists-p dir)
(make-directory dir))
(butlast lst 1))))
(defun mis-slurp (file)
"Return contents of FILE."
(if (file-exists-p file)
(insert-file-contents file)
(error "No file \"%s\" in current directory" file)))
(defun mis-spit (str file)
"Write STR to FILE."
(insert str)
(write-region nil nil file nil 1)))
(defun mis-all-equal (lst)
"Return t if all elements of LST are equal."
(cl-every (lambda (x) (equal x (car lst)))
(cdr lst)))
(defun mis-action (x)
"Make it so for recipe X."
(let* ((sources mis-current-files)
(source (file-name-nondirectory (car sources)))
(ext (file-name-extension source))
(basedir (or (file-name-directory source)
(dir (expand-file-name
(format "%s_%s" x (file-name-nondirectory source))
(mis-build-path (mis-directory) ext x "Makefile"))
(makefile (expand-file-name "Makefile" basedir)))
;; If a recipe exists, copy it.
;; Otherwise create a new one, move it here and mark it to be
;; restored to the proper location.
(if (file-exists-p makefile-template)
(copy-file makefile-template makefile)
(mis-create-makefile x)
(push makefile-template sources))
(if (file-exists-p "Makefile")
(let ((requires (shell-command-to-string "make -s require")))
(if (string-match "make:" requires)
(error "Makefile must have a \"require\" target")
(mkdir dir)
(rename-file "Makefile" dir)
(setq sources
(append sources
(mapcar 'expand-file-name
(split-string requires "\n" t))))))
(mkdir dir))
(let ((targets (mapcar (lambda (x) (mis-rename-quote x dir))
(mis-spit (prin1-to-string sources)
(expand-file-name "sources" dir))
(mis-spit (prin1-to-string targets)
(expand-file-name "targets" dir)))
(find-file (expand-file-name "Makefile" dir))))
(defun mis-delete-file (file)
"Delete FILE."
(move-file-to-trash file))
(defun mis-rename-quote (file dir)
"Move FILE to DIR, changing spaces to underscores."
(let ((dest (expand-file-name
" " "_" (file-name-nondirectory file)) dir)))
(rename-file file dest)
(defun mis-rename-unquote (file dir)
"Move FILE to DIR, changing spaces to underscores."
(let ((dest (expand-file-name
"_" " " (file-name-nondirectory file))
(rename-file file dest)
(defun mis-directory ()
"A getter for `mis-recipes-directory'."
(if (file-exists-p mis-recipes-directory)
;; look for recipes in package directory
(let* ((default-directory package-user-dir)
(dirs (file-expand-wildcards "*make-it-so*")))
(if (= 1 (length dirs))
(setq mis-recipes-directory
(mis-build-path (car dirs) "recipes"))
(error "Not one make-it-so in package dir")))))
(provide 'make-it-so)
;;; make-it-so.el ends here