;;; tiny.el --- Quickly generate linear ranges in Emacs

;; Copyright (C) 2013-2015, 2017  Free Software Foundation, Inc.

;; Author: Oleh Krehel <ohwoeowho@gmail.com>
;; URL: https://github.com/abo-abo/tiny
;; Version: 0.2.1
;; Keywords: convenience

;; This file is part of GNU Emacs.

;; GNU Emacs 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.

;; GNU Emacs 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.  If not, see <http://www.gnu.org/licenses/>

;;; Commentary:
;;
;; To set it up, just bind e.g.:
;;
;;     (global-set-key (kbd "C-;") #'tiny-expand)
;;
;; Usage:
;; This extension's main command is `tiny-expand'.
;; It's meant to quickly generate linear ranges, e.g. 5, 6, 7, 8.
;; Some elisp proficiency is an advantage, since you can transform
;; your numeric range with an elisp expression.
;;
;; There's also some emphasis on the brevity of the expression to be
;; expanded: e.g. instead of typing (+ x 2), you can do +x2.
;; You can still do the full thing, but +x2 would save you some
;; key strokes.
;;
;; You can test out the following snippets by positioning the point at
;; the end of the expression and calling `tiny-expand':
;;
;;   m10
;;   m5 10
;;   m5,10
;;   m5 10*xx
;;   m5 10*xx%x
;;   m5 10*xx|0x%x
;;   m25+x?a%c
;;   m25+x?A%c
;;   m97,122(string x)
;;   m97,122stringxx
;;   m97,120stringxupcasex
;;   m97,120stringxupcasex)x
;;   m\n;; 10|%(+ x x) and %(* x x) and %s
;;   m10*2+3x
;;   m\n;; 10expx
;;   m5\n;; 20expx%014.2f
;;   m7|%(expt 2 x)
;;   m, 7|0x%02x
;;   m10|%0.2f
;;   m1\n14|*** TODO http://emacsrocks.com/e%02d.html
;;   m1\n10|convert img%s.jpg -monochrome -resize 50%% -rotate 180 img%s_mono.pdf
;;   (setq foo-list '(m1 11+x96|?%c))
;;   m1\n10listx+x96|convert img%s.jpg -monochrome -resize 50%% -rotate 180 img%c_mono.pdf
;;   m1\n10listxnthxfoo-list|convert img%s.jpg -monochrome -resize 50%% -rotate 180 img%c_mono.pdf
;;   m\n;; 16list*xxx)*xx%s:%s:%s
;;   m\n8|**** TODO Learning from Data Week %(+ x 2) \nSCHEDULED: <%(date "Oct 7" (* x 7))> DEADLINE: <%(date "Oct 14" (* x 7))>
;;
;; As you might have guessed, the syntax is as follows:
;;
;;   m[<range start:=0>][<separator:= >]<range end>[Lisp expr]|[format expr]
;;
;;     x is the default var in the elisp expression.  It will take one by one
;;     the value of all numbers in the range.
;;
;;     | means that elisp expr has ended and format expr has begun.
;;     It can be omitted if the format expr starts with %.
;;     The keys are the same as for format.
;;     In addition %(sexp) forms are allowed.  The sexp can depend on x.
;;
;;   Note that multiple % can be used in the format expression.
;;   In that case:
;;   - if the Lisp expression returns a list, the members of this list
;;     are used in the appropriate place.
;;   - otherwise, it's just the result of the expression repeated as
;;     many times as necessary.
;;
;; Alternatively, if user does not want to type in the "tiny
;; expressions", they can call the `tiny-helper' command that helps
;; construct the "tiny expression", and then expands that.
;;
;; For example, the below two are equivalent:
;;
;;  - Type "m2_9+1*x2"
;;  - M-x tiny-expand
;;
;; OR
;;
;;  - M-x tiny-helper
;;  - 9 RET 2 RET _ RET +1*x2 RET RET (user entry in the interactive prompts)

;;; Code:

(require 'cl-lib)
(require 'help-fns)
(require 'org)

(defvar tiny-beg nil
  "Last matched snippet start position.")

(defvar tiny-end nil
  "Last matched snippet end position.")

;;;###autoload
(defun tiny-expand ()
  "Expand current snippet.
It polls the expander functions one by one
if they can expand the thing at point.
First one to return a string succeeds.
These functions are expected to set `tiny-beg' and `tiny-end'
to the bounds of the snippet that they matched.
At the moment, only `tiny-mapconcat' is supported.
`tiny-mapconcat2' should be added to expand rectangles."
  (interactive)
  (let ((e (tiny-mapconcat)))
    (when e
      (goto-char tiny-beg)
      (delete-region tiny-beg tiny-end)
      (insert (eval e t))
      t)))

(defun tiny-setup-default ()
  "Setup shortcuts."
  (global-set-key (kbd "C-;") 'tiny-expand))

(defun tiny--strip-newlines (str)
  (replace-regexp-in-string "\\\\n" "\n" str))

(defun tiny-mapconcat ()
  "Format output of `tiny-mapconcat-parse'.
Defaults are used in place of null values."
  (let ((parsed (tiny-mapconcat-parse)))
    (when parsed
      (let* ((n0 (string-to-number (or (nth 0 parsed) "0")))
             (n1 (nth 1 parsed))
             (s1 (cond ((null n1)
                        " ")
                       ((equal n1 "m")
                        "")
                       (t
                        n1)))
             (n2 (read (nth 2 parsed)))
             (expr (or (nth 3 parsed) "x"))
             (lexpr (read expr))
             (n-have (if (and (listp lexpr) (eq (car lexpr) 'list))
                         (1- (length lexpr))
                       0))
             (expr (if (zerop n-have) `(list ,lexpr) lexpr))
             (n-have (if (zerop n-have) 1 n-have))
             (tes (tiny-extract-sexps (or (nth 4 parsed) "%s")))
             (fmt (car tes))
             (idx -1)
             (seq (number-sequence n0 n2 (if (>= n0 n2) -1 1))))
        `(mapconcat (lambda (x)
                      (let ((lst ,expr))
                        (format ,(tiny--strip-newlines fmt)
                                ,@(mapcar (lambda (x)
                                            (if x
                                                (read x)
                                              (if (>= (1+ idx) n-have)
                                                  'x
                                                `(nth ,(cl-incf idx) lst))))
                                          (cdr tes)))))
                    ',seq
                    ,(tiny--strip-newlines s1))))))

(defconst tiny-format-str
  (let ((flags "[+ #-0]\\{0,1\\}")
        (width "[0-9]*")
        (precision "\\(?:\\.[0-9]+\\)?")
        (character "[sdoxXefgcS]?"))
    (format "\\(%s%s%s%s\\)([^)]+)"
            flags width precision character)))

(defun tiny-extract-sexps (str)
  "Return (STR & FORMS).
Each element of FORMS corresponds to a `format'-style % form in STR.

  * %% forms are skipped
  * %(sexp) is replaced with %s in STR, and put in FORMS
  * the rest of forms are untouched in STR, and put as nil in FORMS"
  (let ((start 0)
        forms beg fexp)
    (condition-case nil
        (while (setq beg (string-match "%" str start))
          (setq start (1+ beg))

          (cond ((= ?% (aref str (1+ beg)))
                 (cl-incf start))

                ((and (eq beg (string-match tiny-format-str str beg))
                      (setq fexp (match-string-no-properties 1 str)))
                 (cl-incf beg (length fexp))
                 (cl-destructuring-bind (_sexp . end)
                     (read-from-string str beg)
                   (push
                    (replace-regexp-in-string "(date" "(tiny-date"
                                              (substring str beg end))
                    forms)
                   (setq str (concat (substring str 0 beg)
                                     (if (string= fexp "%") "s" "")
                                     (substring str end)))))
                (t (push nil forms))))
      (error (message "Malformed sexp: %s" (substring str beg))))
    (cons str (nreverse forms))))

(defun tiny-mapconcat-parse ()
  "Try to match a snippet of this form:
m[START][SEPARATOR]END[EXPR]|[FORMAT]

* START     - integer (defaults to 0)
* SEPARATOR - string  (defaults to \" \")
* END       - integer (required)
* EXPR      - Lisp expression: function body with argument x (defaults to x)
  Parens are optional if it's unambiguous:
  - `(* 2 (+ x 3))'   <-> *2+x3
  - `(exp x)'         <-> expx
  A closing paren may be added to resolve ambiguity:
  - `(* 2 (+ x 3) x)' <-> *2+x3)
* FORMAT    - string, `format'-style (defaults to \"%s\")
  | separator can be omitted if FORMAT starts with %.

Return nil if nothing was matched, otherwise
 (START SEPARATOR END EXPR FORMAT)"
  (let ((case-fold-search nil)
        n1 s1 n2 expr fmt str)
    (when (catch 'done
            (cond
              ;; either start with a number
              ((looking-back "\\bm\\(-?[0-9]+\\)\\(.*?\\)"
                             (line-beginning-position))
               (setq n1 (match-string-no-properties 1)
                     str (match-string-no-properties 2)
                     tiny-beg (match-beginning 0)
                     tiny-end (match-end 0))
               (when (zerop (length str))
                 (setq n2 n1
                       n1 nil)
                 (throw 'done t)))
              ;; else capture separator, end number, formatter
              ((looking-back "\\bm\\([^%|\n0-9]*\\)\\([0-9]+\\)\\([^\n]*\\)"
                             (line-beginning-position))
               (setq tiny-beg (match-beginning 0))
               (setq tiny-end (match-end 0))
               (let ((separator (match-string-no-properties 1))
                     (end-numbr (match-string-no-properties 2))
                     (formatter (match-string-no-properties 3)))
                 (if (or (string-match-p "[%|]" formatter)
                         (string= "" formatter))
                     (progn
                       (setq str (concat separator end-numbr formatter))
                       (when (zerop (length str))
                         (throw 'done nil)))
                   (throw 'done nil))))
              (t (throw 'done nil)))
            ;; at this point, `str' should be either [sep]<num>[expr][fmt]
            ;; or [expr][fmt]
            ;;
            ;; First, try to match [expr][fmt]
            (when (or (string-match "^\\([^|]*\\)|\\(.*\\)?$" str)
                      (string-match "^\\(.*?\\)\\(%.*\\)?$" str))
              (setq expr (match-string-no-properties 1 str))
              (setq fmt (match-string-no-properties 2 str)))
            ;; If it's a valid expression, we're done
            (when (setq expr (tiny-tokenize expr))
              (setq n2 n1
                    n1 nil)
              (throw 'done t))
            ;; at this point, `str' is [sep]<num>[expr][fmt]
            (if (string-match "^\\([^\n0-9]*?\\)\\(-?[0-9]+\\)\\(.*\\)?$" str)
                (setq s1 (match-string-no-properties 1 str)
                      n2 (match-string-no-properties 2 str)
                      str (match-string-no-properties 3 str))
              ;; here there's only n2 that was matched as n1
              (setq n2 n1
                    n1 nil))
            ;; match expr_fmt
            (unless (zerop (length str))
              (if (or (string-match "^\\([^\n%|]*?\\)|\\([^\n]*\\)?$" str)
                      (string-match "^\\([^\n%|]*?\\)\\(%[^\n]*\\)?$" str))
                  (progn
                    (setq expr (tiny-tokenize (match-string-no-properties 1 str)))
                    (setq fmt (match-string-no-properties 2 str)))
                (error "Couldn't match %s" str)))
            t)
      (when (equal expr "")
        (setq expr nil))
      (list n1 s1 n2 expr fmt))))

;; TODO: check for arity: this doesn't work: exptxy
(defun tiny-tokenize (str)
  "Transform shorthand Lisp expression STR to proper Lisp."
  (if (equal str "")
      ""
    (ignore-errors
      (let ((i 0)
            (j 1)
            (len (length str))
            sym s out allow-spc
            (n-paren 0)
            (expect-fun t))
        (while (< i len)
          (setq s (substring str i j))
          (when (cond
                  ((string= s "x")
                   (push s out)
                   (push " " out))
                  ((string= s " ")
                   (if allow-spc
                       t
                     (error "Unexpected \" \"")))
                  ;; special syntax to read chars
                  ((string= s "?")
                   (setq s (format "%s" (read (substring str i (cl-incf j)))))
                   (push s out)
                   (push " " out))
                  ((string= s ")")
                   ;; expect a close paren only if it's necessary
                   (if (>= n-paren 0)
                       (cl-decf n-paren)
                     (error "Unexpected \")\""))
                   (when (string= (car out) " ")
                     (pop out))
                   (push ")" out)
                   (push " " out))
                  ((string= s "(")
                   ;; open paren is used sometimes
                   ;; when there are numbers in the expression
                   (setq expect-fun t)
                   (cl-incf n-paren)
                   (push "(" out))
                  ((progn (setq sym (intern-soft s))
                          (cond
                            ;; general functionp
                            ((and (not (eq t (help-function-arglist sym)))
                                  (not (eq sym '\,)))
                             (setq expect-fun nil)
                             (setq allow-spc t)
                             ;; (when (zerop n-paren) (push "(" out))
                             (unless (equal (car out) "(")
                               (push "(" out)
                               (cl-incf n-paren))
                             t)
                            ((and sym (boundp sym) (not expect-fun))
                             t)))
                   (push s out)
                   (push " " out))
                  ((numberp (read s))
                   (let* ((num (string-to-number (substring str i)))
                          (num-s (format "%s" num)))
                     (push num-s out)
                     (push " " out)
                     (setq j (+ i (length num-s)))))
                  (t
                   (cl-incf j)
                   nil))
            (setq i j)
            (setq j (1+ i))))
        ;; last space
        (when (string= (car out) " ")
          (pop out))
        (concat
         (apply #'concat (nreverse out))
         (make-string n-paren ?\)))))))

(defun tiny-date (s &optional shift)
  "Return date representation of S.
`org-mode' format is used.
Optional SHIFT argument is the integer amount of days to shift."
  (let* ((ct (decode-time (current-time)))
         (time (apply 'encode-time
                      (org-read-date-analyze
                       s nil
                       ct)))
         (formatter
          (if (equal (cl-subseq ct 1 3)
                     (cl-subseq (decode-time time) 1 3))
              "%Y-%m-%d %a"
            "%Y-%m-%d %a %H:%M")))
    (when shift
      (setq time (time-add time (days-to-time shift))))
    (format-time-string formatter time)))

(defun tiny--message (str)
  (replace-regexp-in-string
   "%" "%%" str))

;;;###autoload
(defun tiny-helper (&optional end-val begin-val sep op fmt)
  "Helper function for `tiny-expand'.

The arguments to this function construct a “tiny expression”
\"mBSEO|F\" where
  E is the end value (END-VAL)     - defaults to 0 internally if nil or \"\",
                                      or 9 if BEGIN-VAL is nil or \"\" too.
  B is the begin value (BEGIN-VAL) - defaults to 0 internally if nil or \"\".
  S is the separator (SEP)         - defaults to \" \" if nil or \"\".
  O is the elisp operation (OP)    - defaults to \"\" if nil.
  F is the format (FMT)            - defaults to \"\" if nil.

If `tiny' expansion is possible at point, do it.
Otherwise activate the helper to generate a valid “tiny
expression” and expand that.

Usage: Call TINY-HELPER, ↵↵↵↵↵            -> 0 1 2 3 4 5 6 7 8 9
       Call TINY-HELPER, 9↵2↵_↵+1*x2↵↵    -> 5_7_9_11_13_15_17_19
       Call TINY-HELPER, 15↵1↵↵-30*2x↵%x↵ -> 1c 1a 18 16 14 12 10 e c a 8 6 4 2 0"
  (interactive
   (unless (or
            ;; Use the helper only if tiny expansion is not
            ;; possible at point and if the buffer is editable.d
            (barf-if-buffer-read-only)
            (tiny-mapconcat))
     (let ((prompt (propertize "tiny-helper: " 'face 'font-lock-function-name-face)))
       (list (read-string (concat prompt
                                  "END value "
                                  "[Hit RET for default=0; "
                                  "Auto-set to 9 if both begin and end values are 0]: "))
             (read-string (concat prompt
                                  "BEGIN value "
                                  "[Hit RET for default=0; "
                                  "Has to be *smaller* than the end value]: "))
             (read-string (concat prompt
                                  "Separator "
                                  "[Hit RET for default=Space; "
                                  "eg: \\n; No math operators like - or = allowed]: "))
             (read-string (concat prompt
                                  "Lisp Operation "
                                  "[Hit RET for default=\"\" (no Lisp operation); "
                                  "Parentheses are optional; eg: *xx | (+ x ?A) | *2+3x]: "))
             (read-string (concat prompt
                                  "Format "
                                  "[Hit RET for default=\"\" (%0d); "
                                  "eg: %x | 0x%x | %c | %s | %(+ x x) | "
                                  "%014.2f | %03d; Parentheses required here for sexps]: "))))))
  (barf-if-buffer-read-only)  ;Proceed only if the buffer is editable.
  ;; Use the helper to derive a "tiny expression" if tiny expansion is
  ;; not possible at point.
  (when (null (tiny-mapconcat))
    (let* ((tiny-key-binding (or (substitute-command-keys "\\[tiny-helper]")
                                 (substitute-command-keys "\\[tiny-expand]")))
           (end-val (if (null end-val) "" end-val)) ;Initialize to empty strings for non-interactive use.
           (begin-val (if (null begin-val) "" begin-val))
           (sep (if (null sep) "" sep))
           (op (if (null op) "" op))
           (fmt (if (null fmt) "" fmt))
           (end-val-num (string-to-number end-val)) ;Note that (string-to-number "") -> 0
           (begin-val-num (string-to-number begin-val))
           tiny-expr)
      ;; BEGIN-VAL and END-VAL sanity check.
      (cond
       ((= end-val-num begin-val-num)
        (if (zerop end-val-num)
            ;; If both are zero, set the end value to 9 (arbitrarily chosen).
            (setq end-val "9")
          (user-error (format "Begin value (%s) and End value (%s) cannot be the same"
                              begin-val end-val))))
       ((< end-val-num begin-val-num)
        (user-error (format "End value (%s) has to be greater than the begin value (%s)"
                            begin-val end-val))))
      ;; SEP cannot be an empty string if BEGIN-VAL is a non-empty string.
      ;; It is OK to not specify BEGIN-VAL if it is 0.
      (when (and (not (string= begin-val ""))
                 (string= sep ""))
        (setq sep " "))
      ;; When non-empty, prefix FMT with the | char for reading clarity.
      (when (not (string= fmt ""))
        (setq fmt (concat "|" fmt)))
      (setq tiny-expr (concat "m" begin-val sep end-val op fmt))
      (tiny--message
       (format "This %s expansion can also be done by typing %s and then %s"
               (propertize "tiny"
                           'face 'font-lock-function-name-face)
               (propertize tiny-expr
                           'face 'font-lock-keyword-face)
               (if (stringp tiny-key-binding)
                   (propertize tiny-key-binding
                               'face 'font-lock-keyword-face)
                 (concat
                  (propertize "M-x tiny-helper"
                              'face 'font-lock-keyword-face)
                  " or "
                  (propertize "M-x tiny-expand"
                              'face 'font-lock-keyword-face)))))
      (insert tiny-expr)
      (undo-boundary)))
  (tiny-expand))


(provide 'tiny)
;;; tiny.el ends here
