;;; syd-lisp-lib.el -*- lexical-binding: t; -*-

(require 'general)
(require 'clj-lib)

(use-package smartparens
  :defer t)

(use-package evil-surround
  :defer t)

;; Include various lispy symbols as word constituents.
(dolist (c '(?- ?_ ?? ?! ?+ ?* ?/ ?: ?> ?< ?= ?&))
  (modify-syntax-entry c "w" lisp-data-mode-syntax-table))

;;;###autoload
(defvar-keymap syd-lisp-mode-map
  :doc "Keymap for `syd-lisp-mode'.")

;;;###autoload
(define-minor-mode syd-lisp-mode
  "A minor mode for editing lispy languages."
  :keymap syd-lisp-mode-map)

;;;###autoload
(defun syd-wrap-sexp (char)
  "Wrap the sexp at point (using `smartparens') with the pair corresponding to
CHAR (using `evil-surround').  Unlike other `evil-surround' operations, the
point will be preserved and the wrapped region will be re-indented."
  (interactive (evil-surround-input-char))
  (sp-get (sp-get-thing)
    (save-excursion
      (evil-surround-region :beg :end 'inclusive char)
      (indent-region :beg :end))))

;;;###autoload
(evil-define-motion syd-get-enclosing-sexp ()
  "Like `sp-get-enclosing-sexp', but with a slightly different meaning of
\"enclosing sexp\" that matches Vim-sexp's"
  (or (let ((sexp-at-point (sp-get-sexp)))
        (sp-get sexp-at-point
          (when (or (and :beg (= (point) :beg))
                    (and :end (= (point) (- :end 1))))
            sexp-at-point)))
      (let ((sp-enclosing-sexp (sp-get-enclosing-sexp)))
        (sp-get sp-enclosing-sexp
          (when :beg
            sp-enclosing-sexp)))))

;;;###autoload
(evil-define-motion syd-backward-up-sexp (count)
  "Move point to the opening bracket of the enclosing sexp.  The precise meaning
of \"enclosing sexp\" differs slightly from that used by Smartparens for the
sake of a more Vim-like feel inspired by vim-sexp."
  :type exclusive
  (dotimes (_ (or count 1))
    ;; REVIEW: Is there a better way to do this?  I'm slightly uncomfortable
    ;; calling two different `sp-get-*' functions.
    (or (sp-get (sp-get-sexp)
	  (when (and :end (= (point) (- :end 1)))
	    (goto-char :beg)))
	(sp-get (sp-get-enclosing-sexp)
	  (when :beg
	    (goto-char :beg))))))

;;;###autoload
(evil-define-motion syd-forward-up-sexp (&optional count)
  "Move point to the closing bracket of the enclosing sexp.  See
`syd-backward-up-sexp'."
  :type exclusive
  (dotimes (_ (or count 1))
    (or (sp-get (sp-get-sexp)
          (when (and :beg (= (point) :beg))
            (goto-char (- :end 1))))
        (sp-get (sp-get-enclosing-sexp)
          (when :end
            (if (= (point) (- :end 1))
                (sp-get (save-excursion (forward-char)
                                        (sp-get-enclosing-sexp))
                  (when :end
                    (goto-char (- :end 1))))
              (goto-char (- :end 1))))))))

;;;###autoload
(defun syd-get-top-level-sexp ()
  "Get the top-level sexp enclosing point.  Destructure with `sp-get'.'"
  ;; The end position returned by `bounds-of-thing-at-point' includes an
  ;; unpredictable amount of trailing whitespace, so we discard it and compute
  ;; our own figure.
  (let ((original-point (point)))
    (-when-let ((beg . _) (bounds-of-thing-at-point 'defun))
     (save-excursion
       (goto-char beg)
       ;; We can trust Smarparents to get the desired end position.
       (-let* ((top-level-sexp (sp-get-sexp))
               ((_ . end) (sp-get top-level-sexp (cons :beg :end))))
         ;; If the sexp is behind point, we aren't interested in it; find one
         ;; /ahead/ of point.
         (if (< original-point end)
             top-level-sexp
           (goto-char end)
           (sp-next-sexp)
           (sp-get-sexp)))))))

;;;###autoload
(defun syd-get-top-level-sexp-and-attached-comment-bounds ()
  "Get the bounds of top-level sexp enclosing point and the \"attached\"
comment, if there is one.  Returns nil or a pair (BEG . END)."
  (-when-let ((beg . end) (sp-get (syd-get-top-level-sexp) (cons :beg :end)))
    (let ((attached-comment-beg (save-excursion
                                  (goto-char beg)
                                  (syd-sexp--backward-attached-comment))))
      (cons (or attached-comment-beg beg)
            end))))

(evil-define-motion syd-forward-defun (count)
  :jump t
  (sp-get (syd-get-top-level-sexp)
    (goto-char :beg)
    (dotimes (_ (or count 1))
      (sp-next-sexp))))

(defvar syd-sexp-cleanup-operators '(evil-delete)
  "When `syd-evil-a-defun' is used in combination with one of these operators,
some cleanup will be performed.")

;; FIXME(#12): Comments should only attach to the *immediately* following sexp.
;; Consider the following snippet:
;;
;;   ;; Call the continuation if non-nil.  Wraps the return value in a singleton
;;   ;; list for "affine" use with unquote-splicing.
;;   (let ((call-cont (lambda (cont arg)
;;                      (if cont
;;                          (list (funcall cont arg))
;;                        nil)))
;;         names)
;;     ...)
;;
;; The curreny behaviour of `syd-sexp--backward-attached-comment' considers the
;; comment to be attached to both the (let ...) form, as well as the ((call-cont
;; ...)) form and the (call-cont ...) form.  Not good!
(defun syd-sexp--backward-attached-comment ()
  "Assuming point is on the opening delimiter of a sexp, move point backward to
the beginning of the \"attached\" comment."
  (let ((sexp-line (line-number-at-pos))
        (sexp-column (current-column)))
    (-when-let ((beg . _end) (save-excursion
                               (goto-line (- sexp-line 1))
                               (evil-forward-char sexp-column t)
                               (sp-get-comment-bounds)))
      (goto-char beg))))

;;;###autoload
(evil-define-text-object syd-evil-a-defun (count _beg _end _type)
  "Selects the enclosing top-level sexp.  With a COUNT of N, that many
consequtive top-level sexps will be selected.  TODO: Special care will be taken
to clean up whitespace following certain operators."
  :type inclusive
  (when (< count 0)
    (user-error "TODO: Negative count"))
  (-let ((cleanup-p (memq evil-this-operator syd-sexp-cleanup-operators))
         ((beg-0 . end-0)
          (syd-get-top-level-sexp-and-attached-comment-bounds)))
    (if (or (null count) (= count 1))
        (list beg-0 end-0)
      (goto-char end-0)
      (dotimes (_ (- count 1))
        (sp-next-sexp))
      (sp-get (sp-get-sexp)
        (list beg-0 :end)))))

;; IDEA: How about the inner-defun text object selects the defun /without/ the
;; comment?  Is that more useful, or less?  I can't think of the last time Ive
;; needed the top-level sexp without the brackets.

;;;###autoload
(evil-define-text-object syd-evil-inner-defun (_count _beg _end _type)
  "Select the *content* of the enclosing top-level sexp, i.e. without the
delimiters."
  :type inclusive
  (sp-get (syd-get-top-level-sexp)
    (list (+ :beg 1)
          (- :end 1))))

(defun syd-sexp--forward-trailing-whitespace (sexp)
  "Move point to the end of the whitespace trailing after SEXP."
  (goto-char (sp-get sexp :end))
  (skip-chars-forward "[:blank:]")
  (when (= (char-after) ?\n)
    (forward-char)
    (skip-chars-forward "[:blank:]")))

(defun syd-sexp--backward-leading-whitespace (sexp)
  "Move point to the beginning of the whitespace preceding SEXP."
  (goto-char (sp-get sexp :beg))
  (skip-chars-backward "[:blank:]")
  (when (= (char-before) ?\n)
    (backward-char)
    (skip-chars-backward "[:blank:]")))

;;;###autoload
(evil-define-text-object syd-evil-a-form (count _beg _end _type)
  (let* ((cleanup-p (memq evil-this-operator syd-sexp-cleanup-operators))
         (sexp (syd-get-enclosing-sexp)))
    (if cleanup-p
        (save-excursion
          (goto-char (sp-get sexp :beg))
          (if (syd-sexp--looking-at-last-p)
              (progn (syd-sexp--backward-leading-whitespace sexp)
                     (list (point) (sp-get sexp :end)))
            (syd-sexp--forward-trailing-whitespace sexp)
            (list (sp-get sexp :beg) (point))))
      (sp-get sexp (list :beg :end)))))

;;;###autoload
(evil-define-text-object syd-evil-inner-form (count _beg _end _type)
  (sp-get (syd-get-enclosing-sexp)
    (list (+ :beg 1) (- :end 1))))

;;;###autoload
(evil-define-command syd-open-sexp-below ()
  "Insert a newline with appropriate indentation after the enclosing sexp.  A
sexp-wise analogue to Evil's line-wise `evil-open-below'."
  :suppress-operator t
  (evil-with-single-undo
    ;; We want to add an additional blank line when operating at the top level.
    ;; Instead of parsing upward until we can no longer find an enclosing sexp,
    ;; we simply check if the opening bracket is on the first column.  This is
    ;; not very correct, but it's way less work (for myself and the CPU).  If we
    ;; switch to a tree-sitter–based parser, I'd love to switch to the correct
    ;; algorithm.
    (-let* (((beg . end) (sp-get (syd-get-enclosing-sexp) (cons :beg :end)))
            (col (save-excursion (goto-char beg) (current-column))))
      (goto-char end)
      (if (= col 0)
          (newline 2)
        (newline-and-indent))))
  (evil-insert-state 1))

;;;###autoload
(evil-define-command syd-open-sexp-above ()
  "Insert a newline with appropriate indentation above the enclosing sexp.  A
sexp-wise analogue to Evil's line-wise `evil-open-above'."
  :suppress-operator t
  (evil-with-single-undo
    (let ((beg (sp-get (syd-get-enclosing-sexp) :beg)))
      (goto-char beg)
      (syd-sexp--backward-attached-comment)
      (let ((col (current-column)))
        (save-excursion
          ;; We want to add an additional blank line when operating at the top
          ;; level.  Instead of parsing upward until we can no longer find an
          ;; enclosing sexp, we simply check if the opening bracket is on the
          ;; first column.  This is not very correct, but it's way less work (for
          ;; myself and the CPU).  If we switch to a tree-sitter–based parser, I'd
          ;; love to switch to the correct algorithm.
          (if (= col 0)
              (newline 2)
            (newline-and-indent)))
        (indent-to col)
        (evil-insert-state 1)))))

(defun syd-sexp-get-last-thing ()
  (-let (((enclosing-beg . enclosing-end)
          (sp-get (syd-get-enclosing-sexp) (cons :beg :end))))
    (save-excursion
      ;; Imperative andy. }:\
      (let (thing)
        (while (sp-get (syd-get-thing)
                 (and (< enclosing-beg :beg enclosing-end)
                      (< enclosing-beg :end enclosing-end))))))))

(defun syd-sexp--looking-at-last-p ()
  "Return non-nil if the sexp beginning at point is the last element of its
enclosing sexp."
  (save-excursion
    (let ((point-0 (point))
          (sexp (sp-get-enclosing-sexp)))
      (sp-next-sexp)
      (if sexp
          (or
           ;; If `sp-next-sexp' moved backwards, `point-0' was the last
           ;; element.
           (<= (point) point-0)
           ;; If `sp-next-sexp' moved outside of the previously-enclosing
           ;; sexp, `point-0' was final.
           (<= (sp-get sexp :end) (point)))
        ;; No enclosing sexp — we're looking at a top-level sexp.
        (= (point) point-0)))))

(defun syd-sexp--next-thing ()
  "Helper for `syd-sexo->'.  Find the next thing relative to the sexp assumed to
begin at point, and the region covering the closing delimiters."
  (save-excursion
    (condition-case err
        (cl-loop for relative-height from 0
                 while (syd-sexp--looking-at-last-p)
                 do (or (sp-backward-up-sexp)
                        ;; Nothing to slurp!
                        (signal 'top))
                 finally return (cons (sp-next-sexp) relative-height))
      (top nil))))

(defun syd-sexp--slurp-forward ()
  "Slurp forward.  Do not call this function directly; see `syd-sexp->'."
  ;; REVIEW: This is rather unoptimised when used with a count.
  (when-let* ((consumer (sp-get-sexp)))
    (goto-char (sp-get consumer :beg))
    (-if-let ((next-thing . relative-height) (syd-sexp--next-thing))
        (progn (goto-char (sp-get consumer :beg-in))
               (sp-forward-slurp-sexp (+ 1 relative-height))
               (sp-get (sp-get-enclosing-sexp)
                 (goto-char (- :end 1))))
      (user-error "ra"))))

(defun syd-sexp--barf-forward ()
  "Barf forward.  Do not call this function directly; see `syd-sexp-<'."
  (sp-forward-barf-sexp))

;;;###autoload
(evil-define-command syd-sexp-> (&optional count)
  (interactive "<c>")
  (evil-with-single-undo
    (when-let* ((sexp (sp-get-sexp)))
      (let ((fn (cond ((= (point) (sp-get sexp (- :end 1)))
                       #'syd-sexp--slurp-forward))))
        (dotimes (_ (or count 1))
          (funcall fn))))))

;;;###autoload
(evil-define-command syd-sexp-< (&optional count)
  (interactive "<c>")
  (evil-with-single-undo
    (when-let* ((sexp (sp-get-sexp)))
      (let ((fn (cond ((= (point) (sp-get sexp (- :end 1)))
                       #'syd-sexp--barf-forward))))
        (dotimes (_ (or count 1))
          (funcall fn))))))

(defun syd-sexp--looking-at-delimiter-p ()
  (sp-get (sp-get-sexp)
    (and (not (sp-point-in-string-or-comment))
         (or (= (point) :beg)
             (= (point) (- :end 1))))))

;; REVIEW: It might be neat to, iff the point is already in a comment/string,
;; goto delimiters that are also in comments/strings.  For now, let's just
;; ignore comments.
(defun syd-sexp--goto-delimiter (delimiter-type direction count)
  (let* ((point-0 (point))
         (delimiters (mapcar (clj-condp eq delimiter-type
                               ('opening #'car)
                               ('closing #'cdr))
                             sp-pair-list))
         (delimiter-regexp (rx-to-string `(or ,@delimiters)))
         (forward-p (clj-condp eq direction
                      ('forward t)
                      ('backward nil)
                      (t (error "todo errrrare"))))
         (move (lambda ()
                 ;; `forward-p' never changes between calls to `move'; we are
                 ;; doing many more checks than we need to.
                 (and (condition-case er
                          (prog1 t (when forward-p
                                     (forward-char)))
                        (end-of-buffer (throw 'no-move 'no-move)))
                      (if (if forward-p
                              (re-search-forward delimiter-regexp nil t)
                            (re-search-backward delimiter-regexp nil t))
                          (goto-char (match-beginning 0))
                        (throw 'no-move 'no-move))))))
    ;; If `syd-sexp--looking-at-delimiter-p' returns nil, we may be looking at
    ;; the right string of characters, but we are likely inside of a string,
    ;; or a comment, or something.  If we aren't at a "real" delimiter, move
    ;; again.
    (let ((r (catch 'no-move
               (dotimes (_ count)
                 (while (and (funcall move)
                             (not (syd-sexp--looking-at-delimiter-p))))))))
      (if (eq r 'no-move)
          (progn (goto-char point-0)
                 (user-error "Nowhere to go"))
        r))))

(evil-define-motion syd-sexp-forward-opening (count)
  (syd-sexp--goto-delimiter 'opening 'forward (or count 1)))

(evil-define-motion syd-sexp-backward-opening (count)
  (syd-sexp--goto-delimiter 'opening 'backward (or count 1)))

(evil-define-motion syd-sexp-forward-closing (count)
  (syd-sexp--goto-delimiter 'closing 'forward (or count 1)))

(evil-define-motion syd-sexp-backward-closing (count)
  (syd-sexp--goto-delimiter 'closing 'backward (or count 1)))

(defun syd-sexp-get-sexp-with-prefix ()
  (-when-let* ((thing (sp-get-thing))
               ;; TODO: Rewrite using :beg-prf
               ((beg . prefix) (sp-get thing (cons :beg :prefix)))
               (prefix-beg (- beg (length prefix))))
    ;; HACK: Relies on Smartparen's internal representation, which
    ;; they explicitly recommend against.  This could break at any
    ;; time!
    ;; Reminder that `plist-put' is an in-place update. }:)
    (plist-put thing :beg prefix-beg)
    (plist-put thing :prefix "")
    (goto-char prefix-beg)
    thing))

(evil-define-motion syd-sexp-next (count)
  "Like `sp-next-sexp', but prefixes will be considered as part of the sexp."
  ;; If point is resting on a prefix when `syd-sexp-next' is called,
  ;; `sp-next-sexp' will move to the beginning of the prefixed form.  This is
  ;; undesirable, as `syd-sexp-next' considers the prefix and the prefixed form
  ;; to be a single thing.  To get around this, we make sure to move point past
  ;; the prefixed sexp.
  (let ((count* (or count 1)))
    (when-let* ((_ (<= 0 count*))
                (first-prefixed-sexp (syd-sexp-get-sexp-with-prefix)))
      (sp-get first-prefixed-sexp
        (when (<= :beg (point) :end)
          (goto-char :end))))
    (let ((current-prefix-arg count*))
      (call-interactively #'sp-next-sexp)))
  (syd-sexp-get-sexp-with-prefix))

(evil-define-motion syd-sexp-previous (count)
  "Like `sp-next-sexp' (as if called with a negative count), but prefixes will
be considered as part of the sexp."
  (syd-sexp-next (- (or count 1))))

;;;###autoload
(evil-define-command syd-sexp-insert ()
  (evil-with-single-undo
    (sp-get (syd-get-enclosing-sexp)
      (goto-char (+ 1 :beg))
      (save-excursion (insert-char ?\s))
      (evil-insert-state 1))))

;;;###autoload
(evil-define-command syd-sexp-append ()
  (evil-with-single-undo
    (sp-get (syd-get-enclosing-sexp)
      (goto-char (- :end 1))
      (evil-insert-state 1))))

;; Text objects.
(general-def
  :keymaps 'syd-lisp-mode-map
  :states '(visual operator)
  "ad" #'syd-evil-a-defun
  "id" #'syd-evil-inner-defun
  "af" #'syd-evil-a-form
  "if" #'syd-evil-inner-form)

(general-def
  :keymaps 'syd-lisp-mode-map
  :states 'insert
  ";" #'sp-comment)

;; Bind editing commands in normal node, and motion commands in motion
;; mode.
(general-def
  :keymaps 'syd-lisp-mode-map
  :states 'normal
  ">" #'syd-sexp->
  "<" #'syd-sexp-<
  "M-w" #'syd-wrap-sexp
  "M-r" #'sp-raise-sexp
  "M-c" #'sp-clone-sexp
  "M-S" #'sp-split-sexp
  "M-J" #'sp-join-sexp
  "M-u" #'sp-splice-sexp-killing-backward
  "M-U" #'sp-splice-sexp-killing-around
  "M-v" #'sp-convolute-sexp
  "M-o" #'syd-open-sexp-below
  "M-O" #'syd-open-sexp-above
  "M-i" #'syd-sexp-insert
  "M-a" #'syd-sexp-append)

;; Bind editing commands in normal node, and motion commands in motion
;; mode.
(general-def
  :keymaps 'syd-lisp-mode-map
  :states 'motion
  "C-h" #'sp-backward-up-sexp ; Probably deprecated.
  "C-j" #'syd-sexp-next ; Probably deprecated.
  "C-k" #'syd-sexp-previous ; Probably deprecated.
  "C-l" #'sp-down-sexp ; Probably deprecated.

  "M-h" #'sp-backward-up-sexp
  "M-j" #'syd-sexp-next
  "M-k" #'syd-sexp-previous
  "M-l" #'sp-down-sexp

  "(" #'syd-backward-up-sexp
  ")" #'syd-forward-up-sexp
  "{" #'syd-sexp-backward-opening
  "}" #'syd-sexp-forward-opening
  "M-{" #'syd-sexp-backward-closing
  "M-}" #'syd-sexp-forward-closing)

(with-eval-after-load 'smartparens
  (setq
   ;; By default, Smartparens will move backwards to the initial character of
   ;; the enclosing expression, and only move forwards when the point is already
   ;; on that initial character.  This is not expected behaviour for an ex-Vim
   ;; user.
   sp-navigate-interactive-always-progress-point t))

(provide 'syd-lisp-lib)
