;;; template-dumper.el --- Create files from yasnippet templates -*- lexical-binding: t -*-

;; Author: Nathan Nichols
;; Maintainer: Nathan Nichols
;; Package-Version: 20260723.1912
;; Package-Revision: a60b7f840ffc
;; Package-Requires: ((emacs "28.1") (yasnippet "0.14.0") (f "0.20.0") (templatel "20210902.228"))
;; Package
;; Homepage: https://resultsmotivated.com/
;; Keywords: yasnippet templating convenience tools


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


;;; Commentary:

;; Create new files with yas-snippet templates.  Also has some
;; utilities for creating files with dynamically generated file names.

;;; Code:

;;; -*- mode: emacs-lisp; -*-
;; Created on 2023-12-23T10:33:01-05:00
;; @author: nate

(require 'cl-lib)
(require 'yasnippet)
(require 'f)
(require 'templatel)

(defun template-dumper-insert-yas-by-name (name)
  "Insert Yas-snippet NAME at point."
  (catch 'notfound
    (let ((yas-prompt-functions
           (list (lambda (_prompt choices &optional display-fn)
                   (or (cl-find name choices :key display-fn :test #'string=)
                       (throw 'notfound nil))))))
      (yas-insert-snippet t))))

(defun template-dumper-mkdir-p (dirname)
  "Create a directory DIRNAME if one does not exist."
  (if (not (file-exists-p dirname))
      (make-directory dirname t)))

(defun template-dumper-str-or-fn-callback (input)
  "Evaluate INPUT as a function, or convert to a string."
  (cond ((functionp input) (format "%s" (funcall input)))
        ((stringp input) input)
        (t (format "%s" input))))

(defun template-dumper-new-named-file (outdir callback &optional ext)
  "Create a file `[OUTDIR]/[CALLBACK].[EXT]`.
CALLBACK is a string or a function that returns a string."
  (template-dumper-mkdir-p outdir)
  (let* ((ts (template-dumper-str-or-fn-callback callback))
         (fpath (f-join outdir (concat ts ext))))
    (find-file fpath)
    (message fpath)))

(defun template-dumper-yas-new-file (basedir yas-tpl name-callback &optional ext)
  "Create a file with extension EXT from yas-snippet YAS-TPL in BASEDIR.
Return the path of the newly-created file."
  (let* ((with-dot (cond
                    ((not ext) "")
                    ((equal (length ext) 0) "")
                    ((equal (substring ext 0 1) ".") ext)
                         (t (concat "." ext))))
         (new-file (template-dumper-new-named-file basedir name-callback with-dot)))
    (template-dumper-insert-yas-by-name yas-tpl)
    new-file))

;; ###################################################################
;; Functionality for creating projects from templates
;; ###################################################################

(defun template-dumper-dump-simple (file-path tpl-name &optional templatel_vars)
  "Create file FILE-PATH from yas template TPL-NAME."
  (let* ((file-contents
          (with-temp-buffer
            (yas-minor-mode 1)
            (template-dumper-insert-yas-by-name tpl-name)
            (buffer-substring-no-properties (point-min) (point-max))))
         (file-contents (templatel-render-string file-contents templatel_vars)))
    (f-write-text file-contents 'utf-8 file-path)))

(defun template-dumper-mk-file-tpl (file-path tpl-name &optional templatel_vars)
  "Create file FILE-PATH from Yasnippet template TPL-NAME.
Does not overwrite existing files and creates missing parent
directories as neccessary."
  (if (f-exists-p file-path)
      (message "[template-dumper] File '%s' already exists, skipping..."
               file-path)
    (progn
      (message "[template-dumper] Making file '%s' from template '%s'"
                file-path
                tpl-name)
      (template-dumper-mkdir-p (f-dirname file-path))
      (template-dumper-dump-simple file-path tpl-name templatel_vars))))

;; ###################################################################
;; These functions determine the metadata/structure of a file-spec.
;; ###################################################################

(defun template-dumper-is-file-spec (tree)
  "Whether TREE is a file-spec."
  (and (listp tree)
       (eql 'file (car tree))
       (cl-every #'stringp (cdr tree))))

(defun template-dumper-dump-file-spec (spec &optional templatel_vars)
  "Create a file from a file-spec SPEC."
  (let* ((abs-path (nth 1 spec))
         (tpl-name (nth 2 spec)))
    (template-dumper-mk-file-tpl abs-path tpl-name templatel_vars)))

(defun template-dumper--mk-rel-file (basepath file-spec)
  "Make file-spec FILE-SPEC relative to BASEPATH."
  (let* ((path-orig (nth 1 file-spec))
        (new-path (f-join basepath path-orig)))
    (cons 'file (cons new-path (cdr (cdr file-spec))))))

;; ###################################################################
;; cmd-spec
;; ###################################################################

(defun template-dumper-is-cmd-spec (tree)
  "Whether TREE is a file-spec."
  (and (listp tree)
       (eql 'cmd (car tree))
       (cl-every #'stringp (cdr tree))))

(defun template-dumper--run-cmd (abs-path cmd)
  "Run CMD in a subprocess shell from working directory ABS-PATH."
  (message "[template-dumper] %s: %s" abs-path cmd)
  (template-dumper-mkdir-p abs-path)
  (let* ((default-directory abs-path)
         (output (with-temp-buffer
           (with-environment-variables (("DIR" abs-path))
             (shell-command cmd (current-buffer))
             (buffer-substring (point-min) (point-max))))))
    (message output)
    output))

(defun template-dumper-exec-cmd-spec (spec &optional templatel_vars)
  "Execute cmd-spec SPEC."
  (let* ((abs-path (nth 1 spec))
         (cmd (nth 2 spec)))
    (template-dumper--run-cmd abs-path cmd)))

(defun template-dumper--mk-rel-cmd (basepath cmd-spec)
  "Make cmd-spec CMD-SPEC relative to BASEPATH."
  (let ((with-path (if (eql (length cmd-spec) 2)
                       (cons 'cmd (cons "./" (cdr cmd-spec)))
                     cmd-spec)))
    (let* ((path-orig (nth 1 with-path))
           (new-path (f-join basepath path-orig)))
      (cons 'cmd (cons new-path (cdr (cdr with-path)))))))

;; ###################################################################
;; These two functions are not used internally, the struture of a
;; path-spec is determined by `template-dumper-mk-tree'.
;; ###################################################################

(defun template-dumper-is-path-spec (tree)
  "Whether TREE is a path-spec.
A directory spec is a list starting with the symbol `dir'
followed by a string (the path) and then 0 or more lists."
  (and (listp tree)
       (eql 'dir (car tree))
       (stringp (car (cdr tree)))
       (cl-every #'listp (cdr (cdr tree)))))

(defun template-dumper-dump-path-spec (spec)
  "Create a directory from a path-spec SPEC."
  (let* ((abs-path (nth 1 spec))
         (tpl-name (nth 2 spec)))
    (template-dumper-mk-file-tpl abs-path tpl-name)))

(defun template-dumper--mk-rel-path (basepath path-spec)
  "Make path-spec PATH-SPEC relative to BASEPATH."
  (let* ((path-orig (car (cdr path-spec)))
        (new-path (f-join basepath path-orig)))
    (cons 'dir (cons new-path (cdr (cdr path-spec))))))

;; ###################################################################

(defun template-dumper-mk-rel (basepath spec)
  "Make SPEC relative to BASEPATH.
SPEC is either a file-spec or a path-spec."
  (cond ((template-dumper-is-file-spec spec)
         (template-dumper--mk-rel-file basepath spec))
        ((template-dumper-is-cmd-spec spec)
         (template-dumper--mk-rel-cmd basepath spec))
        ((template-dumper-is-path-spec spec)
         (template-dumper--mk-rel-path basepath spec))
        (t
         (error "[template-dumper] Expected a path-spec, cmd-spec or file-spec"))))

(defun template-dumper-mk-tree (tree &optional templatel_vars)
  "Create a project from a project template spec TREE."
  (interactive)
  (cond ((template-dumper-is-file-spec tree)
         (template-dumper-dump-file-spec tree templatel_vars))
        ((template-dumper-is-cmd-spec tree)
         (template-dumper-exec-cmd-spec tree))
        ((template-dumper-is-path-spec tree)
         (let* ((basepath (car (cdr tree)))
                (tree0 (car (cdr (cdr tree)))))
           (dolist (item tree0)
             ;; Update any paths to be relative to `basepath' then
             ;; recurse on each directory item
             (template-dumper-mk-tree (template-dumper-mk-rel basepath item) templatel_vars)
              )))
        (t
         (error "[template-dumper] Expected a path-spec, cmd-spec or file-spec"))))

(defun template-dumper-mk-proj-tree-rel (tree &optional templatel_vars)
  "Create project from template tree TREE.
User is prompted for the directory under which the template tree is created."
  (interactive)
  (let* ((path (read-directory-name "Enter directory:"))
         (tree-rel (template-dumper-mk-rel path tree)))
    (template-dumper-mk-tree tree-rel templatel_vars)))

;; ###################################################################
;; Templatel integration
;; ###################################################################

(defun template-dumper--preproc-str (elt variables)
  (cond ((stringp elt) (templatel-render-string elt variables))
        ((listp elt) (template-dumper-bfs elt variables))
        (t elt)))

(defun template-dumper--add-or-append (list0 list1)
  (cond ((listp list1) (append list0 `(,list1)))
        (t (append list0 (list list1)))))

(defun template-dumper-bfs (tree_in variables)
  "Traverse TREE_IN and render each leaf node as a template.
TREE_IN is an s-expression.  VARIABLES is a list of templatel
variables."
  (let ((tree_out ()))
    (dolist (elt tree_in tree_out)
      (let ((new_elt (template-dumper--preproc-str elt variables))
            )
        (setq tree_out (template-dumper--add-or-append tree_out new_elt))
        ))
    tree_out))

(defun template-dumper--do-prompt (prompt some-keyseq)
  (let ((keymap (copy-keymap minibuffer-local-map)))
    (define-key keymap (kbd "RET") 'newline)
    (define-key keymap some-keyseq 'exit-minibuffer)
    (read-from-minibuffer prompt nil keymap)))

(defun template-dumper--prompt-vars (varlist)
  (interactive)
  (let (
        (format_str "[C-s to submit] '%s':")
        (submit_key (kbd "C-s"))
        (vars_out ()))
    (dolist (elt varlist vars_out)
      (if (not (symbolp elt))
          (error "Expected a symbol."))
      (setq vars_out
            (template-dumper--add-or-append
             vars_out
             `(,(format "%s" elt) .
               ,(template-dumper--do-prompt (format format_str elt) submit_key))
             )))))

(defun template-dumper--prompt-sub (varlist in_tree)
  (interactive)
  (let ((vars (template-dumper--prompt-vars varlist)))
    (template-dumper-bfs in_tree vars)))

(defun template-dumper-prompt-render (tpl varlist)
  "Render a template after prompting for templatel variables.
TPL is a template-dumper template and VARLIST is a (possibly
empty) list of symbols (Example:`'(var1 var2 var3)` or `'()`)."
  (interactive)
  (let* ((templatel_vars (template-dumper--prompt-vars varlist))
         (tpl2 (template-dumper-bfs tpl templatel_vars)))
    (template-dumper-mk-proj-tree-rel tpl2 templatel_vars)))


;; ###################################################################
;; Extremely basic self-tests
;; ###################################################################
(cl-assert (string= (template-dumper-str-or-fn-callback (lambda () "abcdefg")) "abcdefg"))
(cl-assert (string= (template-dumper-str-or-fn-callback "abcdefg") "abcdefg"))
(cl-assert (string= (template-dumper-str-or-fn-callback 123) "123"))
(cl-assert (equal (template-dumper-bfs '("{{ var0 }}" 13 1 "123ASDF") '(("var0" . "[VAR0]"))) '("[VAR0]" 13 1 "123ASDF")))
;; Verify that the list doesn't get modified when no substitution is made
(let ((tpl '(dir "./"
                 (file "run0.py" "python-default-script-fued")
                 (cmd "chmod +x ./run0.py")
                 (dir "module0" ((file "pyproject.toml" "python-pyproject-toml")
                                 (file "tox.ini" "tox-ini-default")
                                 (file "README.md" "org-default-readme")
                                 (file "LICENSE" "license-expat")
                                 (file ".gitignore" "gitignore-default-file")
                                 (dir "src" ((dir "module0" ((file "__init__.py" "python-default-init-file")
                                                             (file "util.py" "python-util0")))))
                                 (dir "tests" ((file "test_util.py" "python-test-util0")))
                                 (cmd "git init \"$DIR\"")
                                 (cmd "git add \"$DIR\"")
                                 (cmd "git commit -m \"initial\""))))))
  (cl-assert (equal (template-dumper-bfs tpl '(("var0" . "[VAR0]"))) tpl)))


(provide 'template-dumper)
;;; template-dumper.el ends here
