;;; helm-dabbrev.el --- Helm implementation of dabbrev. -*- lexical-binding: t -*-

;; Copyright (C) 2012 ~ 2021 Thierry Volpiatto

;; 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/>.

;;; Code:

(require 'helm)
(require 'helm-lib)
(require 'helm-help)
(require 'helm-elisp) ; For show-completion.

(defgroup helm-dabbrev nil
  "Dabbrev related Applications and libraries for Helm."
  :group 'helm)

(defcustom helm-dabbrev-always-search-all t
  "Always search in all buffers when non--nil.
Note that even if nil, a search in all buffers will occur if the
length of candidates is <= than
`helm-dabbrev-max-length-result'."
  :type 'boolean)

(defcustom helm-dabbrev-candidates-number-limit 1000
  "Maximum number of candidates to collect.

The higher this number is, the slower the computation of
candidates will be.  You can use safely a higher value with
emacs-26+.
Note that this have nothing to do with
`helm-candidate-number-limit', this means that computation of
candidates stop when this value is reached but only
`helm-candidate-number-limit' candidates are displayed in the
Helm buffer."
  :type 'integer)

(defcustom helm-dabbrev-ignored-buffers-regexps
  '("\\*helm" "\\*Messages" "\\*Echo Area" "\\*Buffer List")
  "List of regexps matching names of buffers that `helm-dabbrev' should not check."
  :type '(repeat regexp))

(defcustom helm-dabbrev-related-buffer-fn #'helm-dabbrev--same-major-mode-p
  "A function that decide if a buffer to search in its related to `current-buffer'.

This is actually determined by comparing `major-mode' of the
buffer to search and the `current-buffer'.

The function take one arg, the buffer which is current, look at
`helm-dabbrev--same-major-mode-p' for an example.

When nil all buffers are considered related to `current-buffer'."
  :type 'function)

(defcustom helm-dabbrev-major-mode-assoc nil
  "Major mode association alist.

This allow helm-dabbrev searching in buffers with the associated
`major-mode'.
E.g. (emacs-lisp-mode . lisp-interaction-mode)

will allow searching in the lisp-interaction-mode buffer when
`current-buffer' is an `emacs-lisp-mode' buffer and vice versa
i.e. no need to provide (lisp-interaction-mode .
emacs-lisp-mode) association.

When nil check is the searched buffer has same `major-mode' than
the `current-buffer'.

This has no effect when `helm-dabbrev-related-buffer-fn' is nil
or of course bound to a function that doesn't handle this var."
  :type '(alist :key-type symbol :value-type symbol))

(defcustom helm-dabbrev-lineno-around 30
  "Search first in this number of lines before and after point."
  :type 'integer)

(defcustom helm-dabbrev-cycle-threshold 5
  "Number of time helm-dabbrev cycle before displaying helm completion.
When nil or 0 disable cycling."
  :type '(choice (const :tag "Cycling disabled" nil) integer))

(defcustom helm-dabbrev-case-fold-search 'smart
  "Set `case-fold-search' in `helm-dabbrev'.
Same as `helm-case-fold-search' but for `helm-dabbrev'.
Note that this is not affecting searching in Helm buffer, but the
initial search for all candidates in buffer(s)."
  :type '(choice (const :tag "Ignore case" t)
          (const :tag "Respect case" nil)
          (other :tag "Smart" smart)))

(defvaralias 'helm-dabbrev--regexp 'helm-dabbrev-separator-regexp)
(make-obsolete-variable 'helm-dabbrev--regexp
                        'helm-dabbrev-separator-regexp "2.8.3")
;; Check for beginning of line should happen last (^\n\\|^).
(defvar helm-dabbrev-separator-regexp
  "\\s-\\|\t\\|[(\\[\\{\"'`=<>$;,@.#+]\\|\\s\\\\|^\n\\|^"
  "Regexp matching the start of a dabbrev candidate.")


(defvar helm-dabbrev-map
  (let ((map (make-sparse-keymap)))
    (set-keymap-parent map helm-map)
    (define-key map (kbd "M-/") #'helm-next-line)
    (define-key map (kbd "M-:") #'helm-previous-line)
    map))

;; Internal
(defvar helm-dabbrev--cache nil)
(defvar helm-dabbrev--data nil)
(cl-defstruct helm-dabbrev-info dabbrev limits iterator)
(defvar helm-dabbrev--already-tried nil)
(defvar helm-dabbrev--computing-cache nil
  "[INTERNAL] Flag to notify helm-dabbrev is blocked.
Do nothing when non nil.")

(defun helm-dabbrev--buffer-list ()
  (cl-loop for buf in (buffer-list)
           unless (cl-loop for r in helm-dabbrev-ignored-buffers-regexps
                           thereis (string-match r (buffer-name buf)))
           collect buf))

(defun helm-dabbrev--same-major-mode-p (start-buffer)
  "Decide if current-buffer is related to START-BUFFER."
  (helm-same-major-mode-p start-buffer helm-dabbrev-major-mode-assoc))

(defun helm-dabbrev--collect (str limit ignore-case all)
  (let* ((case-fold-search ignore-case)
         (buffer1 (current-buffer))     ; start buffer.
         (minibuf (minibufferp buffer1))
         results pos-before pos-after)
    (catch 'break
      (dolist (buf (if all (helm-dabbrev--buffer-list)
                     (list (current-buffer))))
        (with-current-buffer buf
          (when (or minibuf ; check against all buffers when in minibuffer.
                    (if helm-dabbrev-related-buffer-fn
                        (funcall helm-dabbrev-related-buffer-fn buffer1)
                      t))
            (save-excursion
              ;; Start searching before thing before point.
              (goto-char (- (point) (length str)))
              ;; Search the last 30 lines BEFORE point and set POS-BEFORE.
              (cl-multiple-value-bind (res _pa pb)
                  (helm-dabbrev--search-and-store str -2 limit results)
                (setq results    res
                      ;; No need to set POS-AFTER here.
                      pos-before pb)))
            (save-excursion
              ;; Search the next 30 lines AFTER point and set POS-AFTER.
              (cl-multiple-value-bind (res pa _pb)
                  (helm-dabbrev--search-and-store str 2 limit results)
                (setq results    res
                      ;; No need to set POS-BEFORE, we keep the last
                      ;; value found.
                      pos-after  pa)))
            (save-excursion
              ;; Search all BEFORE point maybe starting from
              ;; POS-BEFORE to not search again what previously found.
              ;; If limit is reached in previous call of
              ;; `helm-dabbrev--search-and-store' POS-BEFORE is nil and
              ;; goto-char will fail, so check it.
              (when pos-before (goto-char pos-before))
              (cl-multiple-value-bind (res _pa _pb)
                  (helm-dabbrev--search-and-store str -1 limit results)
                ;; No need to set POS-BEFORE and POS-AFTER here.
                (setq results res)))
            (save-excursion
              ;; Search all AFTER point maybe starting from POS-AFTER.
              ;; Same comment as above for POS-AFTER.
              (when pos-after (goto-char pos-after))
              (cl-multiple-value-bind (res _pa _pb)
                  (helm-dabbrev--search-and-store str 1 limit results)
                ;; No need to set POS-BEFORE and POS-AFTER here.
                (setq results res)))))
        (when (>= (length results) limit) (throw 'break nil))))
    (nreverse results)))

(defun helm-dabbrev--search-and-store (pattern direction limit results)
  "Search words or symbols matching PATTERN in DIRECTION up to LIMIT.
Finally returns all matched candidates appended to RESULTS.
Argument DIRECTION can be:
    -  (1):  Search forward from point.
    - (-1):  Search backward from point.
    -  (2):  Search forward from the
             `helm-dabbrev-lineno-around'
             lines after point.
    - (-2):  Search backward from the
             `helm-dabbrev-lineno-around'
             lines before point."
  (let ((res results)
         after before)
    (while (and (<= (length res) limit)
                (cl-case direction
                  (1   (search-forward pattern nil t))
                  (-1  (search-backward pattern nil t))
                  (2   (let ((pos
                              (save-excursion
                                (forward-line
                                 helm-dabbrev-lineno-around)
                                (point))))
                         (setq after pos)
                         (search-forward pattern pos t)))
                  (-2  (let ((pos
                              (save-excursion
                                (forward-line
                                 (- helm-dabbrev-lineno-around))
                                (point))))
                         (setq before pos)
                         (search-backward pattern pos t)))))
      (let* ((mb (match-beginning 0))
             (replace-regexp (concat "\\(" helm-dabbrev-separator-regexp
                                     "\\)\\'"))
             (match-word (helm-dabbrev--search
                          pattern mb replace-regexp)))
        (when (and match-word (not (member match-word res)))
          (push match-word res))))
    (list res after before)))

(defun helm-dabbrev--search (pattern beg sep-regexp)
  "Search word or symbol at point matching PATTERN.
Argument BEG is corresponding to the previous `match-beginning'
search.
The search starts at (1- BEG) with a regexp starting with
`helm-dabbrev-separator-regexp' followed by PATTERN followed by a
regexp matching syntactically any word or symbol.
The possible false positives matching SEP-REGEXP at end are
finally removed."
  (let ((eol (point-at-eol)))
    (save-excursion
      (goto-char (1- beg))
      (when (re-search-forward
             (concat "\\("
                     helm-dabbrev-separator-regexp
                     "\\)"
                     "\\(?99:\\("
                     (regexp-quote pattern)
                     "\\(\\sw\\|\\s_\\)+\\)\\)")
             eol t)
        (replace-regexp-in-string
         sep-regexp ""
         (match-string-no-properties 99))))))

(defun helm-dabbrev--get-candidates (dabbrev &optional limit)
  (cl-assert dabbrev nil "[No Match]")
  (helm-dabbrev--collect
   dabbrev (or limit helm-dabbrev-candidates-number-limit)
   (cl-case helm-dabbrev-case-fold-search
     (smart (helm-set-case-fold-search-1 dabbrev))
     (t helm-dabbrev-case-fold-search))
   helm-dabbrev-always-search-all))

(defun helm-dabbrev-default-action (candidate)
  (with-helm-current-buffer
    (let* ((limits (helm-bounds-of-thing-before-point
                    helm-dabbrev-separator-regexp))
           (beg (car limits))
           (end (point)))
      (run-with-timer
       0.01 nil
       #'helm-insert-completion-at-point
       beg end candidate))))

;;;###autoload
(cl-defun helm-dabbrev ()
  "Preconfigured helm for dynamic abbreviations."
  (interactive)
  (unless helm-dabbrev--computing-cache
    (let ((dabbrev (helm-thing-before-point
                    nil helm-dabbrev-separator-regexp))
          (limits (helm-bounds-of-thing-before-point
                   helm-dabbrev-separator-regexp))
          (enable-recursive-minibuffers t)
          (cycling-disabled-p (or (null helm-dabbrev-cycle-threshold)
                                  (zerop helm-dabbrev-cycle-threshold)))
          (helm-execute-action-at-once-if-one t)
          (helm-quit-if-no-candidate
           (lambda ()
             (message "[Helm-dabbrev: No expansion found]"))))
      (cl-assert (and (stringp dabbrev) (not (string= dabbrev "")))
                 nil "[Helm-dabbrev: Nothing found before point]")
      (when (and
             ;; have been called at least once.
             (helm-dabbrev-info-p helm-dabbrev--data)
             ;; But user have moved with some other command
             ;; in the meaning time.
             (not (eq last-command 'helm-dabbrev)))
        (setq helm-dabbrev--data nil))
      ;; When candidates are requested in helm directly without cycling,
      ;; we need them right now before running helm.
      (when cycling-disabled-p
        (message "Waiting for helm-dabbrev candidates...")
        (setq helm-dabbrev--cache (helm-dabbrev--get-candidates dabbrev)))
      (unless (or cycling-disabled-p
                  (helm-dabbrev-info-p helm-dabbrev--data))
        (setq helm-dabbrev--data
              (make-helm-dabbrev-info
               :dabbrev dabbrev
               :limits limits
               :iterator
               (helm-iter-list
                (cl-loop for i in (helm-dabbrev--get-candidates
                                   dabbrev helm-dabbrev-cycle-threshold)
                         when (string-match-p
                               (concat "^" (regexp-quote dabbrev)) i)
                         collect i)))))
      (let ((iter (and (helm-dabbrev-info-p helm-dabbrev--data)
                       (helm-dabbrev-info-iterator helm-dabbrev--data)))
            deactivate-mark)
        ;; Cycle until iterator is consumed.
        (helm-aif (and iter (helm-iter-next iter))
            (progn
              (helm-insert-completion-at-point
               (car (helm-dabbrev-info-limits helm-dabbrev--data))
               ;; END is the end of the previous inserted string, not
               ;; the end (apart for first insertion) of the initial string.
               (cdr limits) it)
              ;; Move already tried candidates to end of list.
              (push it helm-dabbrev--already-tried))
          ;; Iterator is now empty, or cycling was disabled, maybe
          ;; reset dabbrev to initial value and start helm completion.
          (let* ((old-dabbrev (if (helm-dabbrev-info-p helm-dabbrev--data)
                                  (helm-dabbrev-info-dabbrev helm-dabbrev--data)
                                dabbrev))
                 (only-one (eq (length helm-dabbrev--already-tried) 1)))
            (unless helm-dabbrev--cache ; Already computed when
                                        ; cycling is disabled.
              (message "Waiting for helm-dabbrev candidates...")
              (setq helm-dabbrev--computing-cache t)
              (setq helm-dabbrev--cache
                    (helm-dabbrev--get-candidates old-dabbrev))
              ;; If user continues typing M-/ while display is blocked by
              ;; helm-dabbrev--get-candidates delete these events.
              (setq unread-command-events nil))
            ;; If the length of candidates is only one when computed
            ;; that's mean the unique matched item have already been
            ;; inserted by the iterator, so no need to reinsert the old dabbrev,
            ;; just let helm exiting with "No expansion found".
            (unless (or only-one cycling-disabled-p)
              (setq dabbrev old-dabbrev
                    limits  (helm-dabbrev-info-limits helm-dabbrev--data))
              (setq helm-dabbrev--data nil)
              (delete-region (car limits) (point))
              (insert dabbrev))
            (when (and (null cycling-disabled-p) only-one)
              (setq helm-dabbrev--cache nil
                    helm-dabbrev--already-tried nil
                    helm-dabbrev--computing-cache nil)
              (cl-return-from helm-dabbrev
                (message "[Helm-dabbrev: No expansion found]")))
            (with-helm-show-completion (car limits) (cdr limits)
              (unwind-protect
                  (helm :sources
                        (helm-build-in-buffer-source "Dabbrev Expand"
                          :data
                          (append
                           (cl-loop with lst = helm-dabbrev--cache
                                    for cand in helm-dabbrev--already-tried
                                    do (setq lst (delete cand lst))
                                    finally return lst)
                           helm-dabbrev--already-tried)
                          :persistent-action 'ignore
                          :persistent-help "DoNothing"
                          :keymap helm-dabbrev-map
                          :action 'helm-dabbrev-default-action
                          :group 'helm-dabbrev)
                        :buffer "*helm dabbrev*"
                        :input (concat "^" dabbrev " ")
                        :resume 'noresume
                        :allow-nest t)
                (setq helm-dabbrev--computing-cache nil
                      helm-dabbrev--already-tried   nil
                      helm-dabbrev--cache           nil)))))))))

(provide 'helm-dabbrev)

;;; helm-dabbrev.el ends here
