;;; flylisp.el --- Color unbalanced parentheses and parentheses inconsistent with indentation -*- lexical-binding: t; -*-

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

;; Author: Barry O'Reilly <gundaetiapo@gmail.com>
;; Version: 0.2
;; Package-Requires: ((emacs "24.1") (cl-lib "0.4"))

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

;;; Commentary:

;; Colors mismatched open parentheses with fl-mismatched-face, red by
;; default. Works reliably after Emacs 24.3, in which bug 16247 is
;; fixed.
;;
;; Also colors open and close parentheses which are inconsistent with
;; the indentation of lines between them with fl-inconsistent-face,
;; orange by default. This is useful for the Lisp programmer who
;; infers a close paren's location from the open paren and
;; indentation. The coloring serves as a warning that the indentation
;; misleads about where the close paren is. It may also help to
;; localize the mistake, whether due to a misindented line or a
;; misplaced paren.
;;
;; As an example, consider:
;;
;;   (aaa (bbb "word-a
;;   word-b" (ccc 1
;;                2)
;;        fff))
;;
;; (aaa ...) and (ccc ...) are consistent, so are not colored.
;; (bbb ...) is inconsistent because the indentation of fff is
;; inconsistent with the actual location of the close paren. The open
;; and close paren are thus colored with the fl-inconsistent-face.
;; This example also shows that multi line strings don't cause an
;; inconsistency.
;;
;; Currently, the package only detects close parens that are after the
;; place indentation would predict. A planned feature is to also
;; indicate when the close paren is before.
;;
;; Also planned is to color mismatched close parens.

;;; Code:

;; TODO: Algorithm doesn't account for close paren which is too soon.
;;
;; (abc
;;   (def))
;;   (ghi)
;;
;; (abc ...) are inconsistent parens because (ghi) is indented too far

;; TODO: implement mismatched close parens

;; TODO: Write tests:
;;
;;   ;; Expect (abc ...) is consistent, (def ...) is inconsistent:
;;   (abc a-symbol (a-func-call "word-a
;;   word-b" (def ghi
;;           jkl)
;;
;;   ;; Expect (when ...) is inconsistent:
;;   (when (and t
;;         nil))
;;   ;; After change, expect (when ...) is consistent and last paren mismatched:
;;   (when (and t)
;;         nil))
;;
;;   Given (a ...) inconsistent, change to (a ...(), and verify close
;;   paren is consistent.

(require 'cl-lib)
(require 'jit-lock)

(defgroup flylisp nil
  "Color unbalanced parentheses and parentheses inconsistent with indentation."
  :prefix "flylisp-"
  :group 'paren-matching)

(defgroup flylisp-faces nil
  "Faces for flylisp package. "
  :group 'flylisp
  :group 'faces)

(defface fl-inconsistent-face
  '((((class color) (background light))
     :foreground "dark orange")
    (((class color) (background dark))
     :foreground "orange"))
  "Face applied to matching open and close parens whose placement
is inconsistent with indentation."
  :group 'flylisp-faces)

(defface fl-mismatched-face
  '((((class color) (background light))
     :foreground "dark red")
    (((class color) (background dark))
     :foreground "red"))
  "Face applied to a paren who has no match."
  :group 'flylisp-faces)

;; An open paren and algorithmic data about it.
;;
;; position is the position in the buffer of the open paren
;;
;; close is one of:
;;   - nil if unknown
;;   - the position before the matching close paren
;;   - the symbol 'mismatched if no matching close paren exists
;;
;; column is the displayed column of the open paren in its logical
;; line of the buffer
;;
;; inconsistent is whether the open paren's close paren is
;; inconsistent with the indentation within the list defined by the
;; parens. It is one of:
;;   - nil if unknown or consistent
;;   - an integer offset from the open position to the position of the
;;     first inconsistency. This offset is also cached in the open
;;     paren text properties for performance.
(cl-defstruct fl--Open position close column inconsistent)

(defsubst fl--colorize-inconsistent (open-obj)
  "Colorize the fl--Open OPEN-OBJ as inconsistent."
  (add-text-properties (fl--Open-position open-obj)
                       (1+ (fl--Open-position open-obj))
                       `(fl-inconsistency
                         ,(fl--Open-inconsistent open-obj)
                         font-lock-face
                         fl-inconsistent-face
                         rear-nonsticky
                         t))
  (add-text-properties (fl--Open-close open-obj)
                       (1+ (fl--Open-close open-obj))
                       `(font-lock-face
                         fl-inconsistent-face
                         rear-nonsticky
                         t)))

(defsubst fl--line-check-opens (open-stack)
  "Check fl--Open objects of the OPEN-STACK list for
consistency.

The inconsistent==nil elements of OPEN-STACK must have columns
that are strictly decreasing moving towards the tail (a necessary
but not sufficient condition for being consistent). The
implementation optimizes on this assumption.

Call with point on the line being checked; puts point on the next
line or EOB."
  (let ((indent-pos (progn (back-to-indentation)
                           (point)))
        (indent-column (current-column))
        (line-end (progn (end-of-line)
                         (point))))
    ;; Assess open-objs against indent-column
    (unless (eq indent-pos line-end) ; Skip whitespace lines
      ;; Since we're only interested in marking Opens inconsistent,
      ;; the open-stack's documented property allows the iteration to
      ;; stop at the first inconsistent==nil Open with small enough
      ;; column.
      (while (and open-stack
                  (or (fl--Open-inconsistent (car open-stack))
                      (<= indent-column
                          (fl--Open-column (car open-stack)))))
        ;; Check fl--Open-inconsistent to avoid excessive
        ;; syntax-ppss when there's a lot of bad
        ;; indentation.
        (unless (or (fl--Open-inconsistent (car open-stack))
                    ;; Multi line strings don't cause inconsistency
                    (nth 3 (syntax-ppss indent-pos)))
          (setf (fl--Open-inconsistent (car open-stack))
                (- indent-pos (fl--Open-position (car open-stack)))))
        (pop open-stack)))
    ;; Go to next line. Since we already know line-end, use it
    ;; instead of rescanning the line
    ;;
    ;; goto-char tolerates going beyond EOB
    (goto-char (1+ line-end))))

(defsubst fl--region-check-opens (downward-objs
                                  upward-objs)
  "Check inputted parens in a region for inconsistency, first
going down in sexp depth then up per the DOWNWARD-OBJS and
UPWARD-OBJS.

Point must be at the start of the region to process and will end
up near the end.

DOWNWARD-OBJS is a list of fl--Open objects. Each must be a
parent of the next in the list.

UPWARD-OBJS is a list of fl--Open objects. Each must be a child
of the next in the list."
  (while downward-objs
    (fl--line-check-opens upward-objs)
    (while (and downward-objs
                (< (fl--Open-position (car downward-objs))
                   (point)))
      (push (pop downward-objs)
            upward-objs)))
  (while (and upward-objs
              (number-or-marker-p (fl--Open-close (car upward-objs))))
    (fl--line-check-opens upward-objs)
    (while (and upward-objs
                (number-or-marker-p (fl--Open-close (car upward-objs)))
                (< (fl--Open-close (car upward-objs))
                   (point)))
      (pop upward-objs))))

(defsubst fl--set-closes (open-obj-list)
  "Sets the close attribute of each element of OPEN-OBJ-LIST.

OPEN-OBJ-LIST is a list of fl--Open. Each must be a child of the
next in the list. This is used to scan-lists efficiently."
  ;; Note: Because fl--Open-position values come from (nth 9
  ;; (syntax-ppss)), we know they are not inside a string or comment.
  ;; Thus buf-pos inits to a valid position to start scan-lists from.
  (let ((buf-pos (and open-obj-list
                      ;; scan_lists tolerates buf-pos past EOB
                      (1+ (fl--Open-position (car open-obj-list))))))
    (dolist (open-i open-obj-list)
      (when buf-pos
        (setq buf-pos (condition-case nil
                          (scan-lists buf-pos 1 1)
                        (scan-error nil))))
      (setf (fl--Open-close open-i) (if buf-pos
                                        (1- buf-pos)
                                      'mismatched)))))

(defun fl-propertize-region (start end)
  (save-excursion
    ;; In order to correctly remove faces from parens that changed
    ;; from multiline to uniline, we clear all parens in the JIT lock
    ;; region to start with.
    (fl-unpropertize-region start end)
    (let* ((timing-info (list (current-time)))
           (start-ps (syntax-ppss start))
           ;; Open positions, outer to inner
           (ps-opens (nth 9 start-ps))
           ;; fl--Open objects, positions inner to outer
           (open-objs nil))
      (push (current-time) timing-info)
      ;; Process the broader region spanned by ps-opens. Consider only
      ;; the ps-opens, not their children which lie entirely outside
      ;; the JIT lock region.
      ;;
      ;; We mostly avoid further sexp parsing in the broader region,
      ;; except to check for a multiline string just before setting
      ;; inconsistent.
      (dolist (ps-open-i ps-opens)
        (push (make-fl--Open :position
                             ps-open-i
                             :column
                             (progn
                               (goto-char ps-open-i)
                               (current-column)))
              open-objs))
      (push (current-time) timing-info)
      ;; Filter out parens which don't need consideration outside the
      ;; JIT lock region. The ones that do are currently fontified as
      ;; inconsistent, and could become consistent if all its enclosed
      ;; lines are checked.
      ;;
      ;; In addition to filtering, this passage sets close positions
      ;; and may reapply the inconsistency-face to some close parens
      ;; which were just cleared.
      (setq open-objs
            (let* ((objs-head (cons nil open-objs))
                   (prev-open objs-head)
                   (open-i (cdr objs-head))
                   ;; Whether we've called fl--set-closes
                   ;;
                   ;; fl--set-closes is fairly expensive when near the
                   ;; beginning of a long Lisp function. We can avoid
                   ;; calling it if all open-objs are propertized as
                   ;; consistent or mismatched.
                   (closes-set nil))
              (while open-i
                (let* ((inconsistency-offset
                        (get-text-property (fl--Open-position (car open-i))
                                           'fl-inconsistency))
                       (inconsistency-pos
                        (and inconsistency-offset
                             (+ (fl--Open-position (car open-i))
                                inconsistency-offset))))
                  (if (or (not inconsistency-pos)
                          ;; Always nil so as "or" evaluation continues
                          (unless closes-set
                            ;; Lazy one-time call
                            (fl--set-closes open-objs)
                            (not (setq closes-set t)))
                          ;; Spot check using the cached offset to
                          ;; possibly avoid a complete check in
                          ;; fl--region-check-opens for open-i.
                          ;;
                          ;; Because of buffer changes,
                          ;; inconsistency-pos is not necessarily
                          ;; the original. Just do a valid check.
                          (and (< (fl--Open-position (car open-i))
                                  inconsistency-pos)
                               (number-or-marker-p (fl--Open-close (car open-i)))
                               (<= inconsistency-pos
                                   (fl--Open-close (car open-i)))
                               (progn
                                 (goto-char inconsistency-pos)
                                 (fl--line-check-opens (list (car open-i)))
                                 (when (fl--Open-inconsistent (car open-i))
                                   (fl--colorize-inconsistent (car open-i))
                                   t))))
                      ;; Remove (car open-i) from list
                      (setcdr prev-open (cdr open-i))
                    (pop prev-open))
                  (pop open-i)))
              (cdr objs-head)))
      (push (current-time) timing-info)
      (when open-objs
        ;; Check lists beginning before JIT lock's region (could
        ;; scan to after JIT lock's region)
        (let ((open-objs-reversed (reverse open-objs)))
          (goto-char (fl--Open-position (car open-objs-reversed)))
          (fl--region-check-opens open-objs-reversed
                                  nil)))
      (push (current-time) timing-info)
      (goto-char start)
      ;; Process within the inputted JIT lock region
      (let* (;; Sparse vector of open paren data, indexed by position
             ;; in buffer minus start. This benchmarked better than
             ;; keeping a stack of fl--Open objects updated from the
             ;; parse states of syntax-ppss.
             (open-paren-table (make-vector (- end start) nil)))
        (while (< (point) end)
          (let ((indent-pos (progn (back-to-indentation)
                                   (point)))
                ;; Column at which text starts on the line
                (indent-column (current-column))
                (line-ppss (syntax-ppss))
                (line-end (progn (end-of-line)
                                 (point))))
            ;; Skip whitespace only lines and lines beginning inside
            ;; string
            (unless (or (eq indent-pos line-end)
                        (nth 3 line-ppss))
              ;; Iterate over list of unclosed open parens
              (dolist (open-pos (nth 9 line-ppss))
                ;; Skip the already processed ones outside the region
                (when (<= start open-pos)
                  (let ((open-obj (or (aref open-paren-table
                                            (- open-pos start))
                                      (progn
                                        (push (make-fl--Open
                                               :position open-pos
                                               :column (progn
                                                         (goto-char open-pos)
                                                         (current-column)))
                                              open-objs)
                                        (aset open-paren-table
                                              (- open-pos start)
                                              (car open-objs))))))
                    (when (<= indent-column
                              (fl--Open-column open-obj))
                      (setf (fl--Open-inconsistent open-obj)
                            (- indent-pos (fl--Open-position open-obj))))))))
            ;; Go to next line. Since we already know line-end, use it
            ;; instead of rescanning the line
            (goto-char (1+ line-end))))
        (push (current-time) timing-info)
        ;; Process parens beginning in the JIT lock region but extending after
        ;;
        ;; Note: the reason we don't filter fl--Open after the JIT
        ;; lock region, as we did for the region before it, is mostly
        ;; because of the directionality of redisplay from BOB to EOB.
        ;; If we allow subsequent fl-propertize-region to propertize
        ;; the open parens in the current JIT lock region, it wouldn't
        ;; show to the user because by then redisplay has finished
        ;; this JIT lock region. An additional consideration is that
        ;; the coloring of the open paren is of more interest than the
        ;; close paren.
        (let ((ps-opens (nth 9 (syntax-ppss end)))
              ;; Inner to outer going towards the tail
              (open-obj-list nil))
          (dolist (ps-open-i ps-opens)
            (when (<= start ps-open-i)
              (push (or (aref open-paren-table
                              (- ps-open-i start))
                        ;; Open parens on the last line of the JIT
                        ;; lock region don't have a fl--Open object
                        ;; created yet.
                        (progn
                          (push (make-fl--Open
                                 :position ps-open-i
                                 :column (progn
                                           (goto-char ps-open-i)
                                           (current-column)))
                                open-objs)
                          (aset open-paren-table
                                (- ps-open-i start)
                                (car open-objs))))
                    open-obj-list)))
          (push (current-time) timing-info)
          (fl--set-closes open-obj-list)
          (push (current-time) timing-info)
          (goto-char end)
          (fl--region-check-opens nil open-obj-list))
        (push (current-time) timing-info)
        (dolist (open-i open-objs)
          ;; Set close position
          ;;
          ;; Note: We do it here instead of when it was made so as
          ;; some benefit from the fl--set-closes function's buffer
          ;; scanning optimization. The lists processed here are
          ;; opened and closed within JIT lock's region, so the less
          ;; efficient buffer scanning is not a big deal.
          (unless (fl--Open-close open-i)
            (setf (fl--Open-close open-i)
                  (condition-case nil
                      (1- (scan-lists (fl--Open-position open-i) 1 0))
                    (scan-error 'mismatched))))
          ;; Apply the font color via text properties
          (with-silent-modifications
            (if (eq 'mismatched (fl--Open-close open-i))
                (add-text-properties (fl--Open-position open-i)
                                     (1+ (fl--Open-position open-i))
                                     `(font-lock-face
                                       fl-mismatched-face
                                       rear-nonsticky
                                       t))
              (if (fl--Open-inconsistent open-i)
                  (fl--colorize-inconsistent open-i)
                (dolist (pos-i (list (fl--Open-position open-i)
                                     (fl--Open-close open-i)))
                  (remove-text-properties pos-i
                                          (1+ pos-i)
                                          '(fl-inconsistency
                                            nil
                                            font-lock-face
                                            nil
                                            rear-nonsticky
                                            nil)))))))
        (push (current-time) timing-info)
        ;; (my-msg "fl-propertize-region start=%s end=%s timing: %s"
        ;;         start end
        ;;         (my-time-diffs (nreverse timing-info)))
        ))))

(defun fl-unpropertize-region (start end)
  (goto-char start)
  ;; remove-text-properties errors if (1+ (point)) is past EOB, so
  ;; adjust end
  (let ((end (min (1- (point-max))
                  end)))
    (while (< (point) end)
      (skip-syntax-forward "^()" end)
      (remove-text-properties (point)
                              (1+ (point))
                              '(fl-inconsistency nil
                                font-lock-face nil
                                rear-nonsticky nil))
      (forward-char 1))))

(defvar jit-lock-start)

(defsubst flylisp-extend-region-after-change (start _end _old-len)
  ;; It seems redisplay works its way from before start to after end,
  ;; so it's more important to expand the start in order to get
  ;; correct redisplays.
  (save-excursion
    (setq jit-lock-start
          (or (syntax-ppss-toplevel-pos (syntax-ppss start))
              start))))

;;;###autoload
(define-minor-mode flylisp-mode
  "Color unbalanced parentheses and parentheses inconsistent with
  indentation."
  :lighter nil
  (if flylisp-mode
      (progn
        (jit-lock-register 'fl-propertize-region t)
        (add-hook 'jit-lock-after-change-extend-region-functions
                  #'flylisp-extend-region-after-change
                  nil
                  t))
    (remove-hook 'jit-lock-after-change-extend-region-functions
                 #'flylisp-extend-region-after-change
                 t)
    (jit-lock-unregister 'fl-propertize-region)
    (save-excursion
      (fl-unpropertize-region (point-min) (point-max)))))

(provide 'flylisp)

;;; flylisp.el ends here
