emacs-elpa-diffs
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[elpa] externals/jit-spell 1abd589c8b 1/3: Initial commit


From: ELPA Syncer
Subject: [elpa] externals/jit-spell 1abd589c8b 1/3: Initial commit
Date: Sat, 4 Mar 2023 16:58:01 -0500 (EST)

branch: externals/jit-spell
commit 1abd589c8b352e41af38a57acfd5004abf0e58d2
Author: Augusto Stoffel <arstoffel@gmail.com>
Commit: Augusto Stoffel <arstoffel@gmail.com>

    Initial commit
---
 jit-spell.el | 497 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 497 insertions(+)

diff --git a/jit-spell.el b/jit-spell.el
new file mode 100644
index 0000000000..019570917e
--- /dev/null
+++ b/jit-spell.el
@@ -0,0 +1,497 @@
+;;; jit-spell.el --- Just-in-time spell checking      -*- lexical-binding: t; 
-*-
+
+;; Copyright (C) 2023  Augusto Stoffel
+
+;; Author: Augusto Stoffel <arstoffel@gmail.com>
+;; Keywords: tools, wp
+;; URL: https://github.com/astoff/jit-spell
+;; Package-Requires: ((emacs "27.1") (compat "29.1"))
+;; Version: 0
+
+;; 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:
+
+;; TODO
+
+;;; Code:
+
+(require 'compat)
+(require 'ispell)
+(require 'seq)
+(eval-when-compile (require 'subr-x))
+
+(defgroup jit-spell nil
+  "Check spelling as you type."
+  :prefix "jit-spell-"
+  :group 'ispell
+  :group 'text)
+
+(defface jit-spell-misspelling
+  '((((supports :underline (:style wave)))
+     :underline (:style wave :color "red"))
+    (t
+     :underline t :inherit error))
+  "Face added by `jit-spell-mode' to misspelled words.")
+
+(defcustom jit-spell-current-word-delay 3
+  "Time, in seconds, to wait before highlighting word at point."
+  :type 'number)
+
+(defcustom jit-spell-ignored-faces
+  '(font-latex-math-face
+    font-latex-sedate-face
+    message-header-name)
+  "Faces jit-spell should ignore."
+  :type '(repeat symbol))
+
+(defcustom jit-spell-prog-mode-faces
+  '(font-lock-comment-face
+    font-lock-doc-face
+    font-lock-string-face)
+  "Faces jit-spell should check in modes derived from `prog-mode'."
+  :type '(repeat symbol))
+
+(defvar jit-spell-delayed-commands
+  '(backward-delete-char-untabify
+    self-insert-command)
+  "List of commands with delayed spell checking.
+Wait for `jit-spell-current-word-delay' seconds before
+highlighting a misspelling at point after one of these commands.")
+
+(defvar jit-spell-ignored-p #'jit-spell--default-ignored-p
+  "Predicate satisfied by words to ignore.
+It should be a function taking two arguments, the start and end
+positions of the word.")
+
+(defvar jit-spell--process-pool nil
+  "A collection of ispell processes for `jit-spell-mode'.")
+
+(defvar jit-spell--hidden-overlay nil
+  "Place to keep track of a hidden overlay near the cursor.")
+
+(defvar-local jit-spell--recheck-timer nil
+  "Timer to debounce recheck requests.")
+
+;;; Mode-specific support
+
+(defun jit-spell--default-ignored-p (start end)
+  "Return non-nil if word between START and END should not be spell-checked."
+  (or (get-text-property start 'jit-spell-ignored)
+      (let ((face (get-text-property start 'face)))
+        (if (listp face)
+            (seq-some (lambda (f) (memq f jit-spell-ignored-faces))
+                      face)
+          (memq face jit-spell-ignored-faces)))
+      (member (buffer-substring-no-properties start end)
+              ispell-buffer-session-localwords)))
+
+(defun jit-spell--prog-ignored-p (start _end)
+  "Additional ignore predicate for `prog-mode'."
+  (let ((face (get-text-property start 'face)))
+    (not (if (listp face)
+             (seq-some (lambda (f) (memq f jit-spell-prog-mode-faces))
+                       face)
+           (memq face jit-spell-prog-mode-faces)))))
+
+;; TODO: jit-spell--org-ignored-p, etc.
+
+;;; Overlays
+
+(put 'jit-spell 'evaporate t)
+
+(defun jit-spell--make-overlay (start end corrections)
+  "Make an overlay to highlight incorrect word between START and END.
+Also add the list of CORRECTIONS as a property."
+  (let ((ov (make-overlay start end nil t)))
+    (overlay-put ov 'category 'jit-spell)
+    (overlay-put ov 'jit-spell-corrections corrections)
+    (if (not (<= start (point) end))
+        (overlay-put ov 'face 'jit-spell-misspelling)
+      (jit-spell--unhide-overlay)
+      (setq jit-spell--hidden-overlay
+            `(,(run-with-timer jit-spell-current-word-delay nil
+                               #'jit-spell--unhide-overlay)
+              ,ov
+              jit-spell-misspelling)))
+    ov))
+
+(defun jit-spell--make-overlays (buffer start end misspellings)
+  "Create jit-spell overlays in BUFFER between START and END.
+MISSPELLINGS is a list with elements consisting of a word, a
+character offset from START, and a list of corrections."
+  (with-current-buffer buffer
+    (with-silent-modifications
+      (remove-list-of-text-properties start end '(jit-spell-pending))
+      (remove-overlays start end 'category 'jit-spell)
+      (pcase-dolist (`(,word ,offset ,corrections) misspellings)
+        (let* ((wstart (+ start offset -1))
+               (wend (+ wstart (length word))))
+          (unless (funcall jit-spell-ignored-p wstart wend)
+            (jit-spell--make-overlay wstart wend corrections)))))))
+
+(defun jit-spell--overlay-at (pos)
+  "Return the jit-spell overlay at POS, if it exists."
+  (seq-some (lambda (ov)
+              (and (eq (overlay-get ov 'category) 'jit-spell) ov))
+            (overlays-at pos)))
+
+(defun jit-spell--search-overlay (pos count)
+  "Return the COUNT jit-spell overlay from POS."
+  (let* ((limit (if (> count 0) (window-end) (window-start)))
+         (searchfn (if (> count 0)
+                       #'next-single-char-property-change
+                     #'previous-single-char-property-change))
+         (i (abs count)))
+    (catch 'jit-spell
+      (while (/= pos limit)
+        (setq pos (funcall searchfn pos 'jit-spell-corrections nil limit))
+        (dolist (ov (overlays-at pos))
+          (when (eq (overlay-get ov 'category) 'jit-spell)
+            (cl-decf i)
+            (unless (< 0 i)
+              (throw 'jit-spell ov))))))))
+
+(defun jit-spell--apply-correction (ov text)
+  "Replace region spanned by OV with TEXT."
+  (save-excursion
+    (goto-char (overlay-start ov))
+    (delete-region (point) (overlay-end ov))
+    (insert-before-markers text)))
+
+(defun jit-spell--context-menu (menu click)
+  "Context menu for `jit-spell-mode'.
+MENU and CLICK are as expected of a member of `context-menu-functions'.
+It can also be bound to a mouse click to pop up the menu."
+  (interactive "i\ne")
+  (save-excursion
+    (mouse-set-point click)
+    (when-let ((ov (jit-spell--overlay-at (point)))
+               (word (buffer-substring-no-properties
+                  (overlay-start ov) (overlay-end ov)))
+               (map (or menu (make-sparse-keymap))))
+      (dolist (corr (overlay-get ov 'jit-spell-corrections))
+        (easy-menu-add-item map '("Correct Word")
+                            (vector corr (lambda () (interactive)
+                                           (jit-spell--apply-correction ov 
corr)))))
+      (easy-menu-add-item map nil `["Save to Dictionary"
+                                    (jit-spell-accept-word ,word 'dict)])
+      (easy-menu-add-item map nil `["Save to Buffer"
+                                    (jit-spell-accept-word ,word 'buffer)])
+      (easy-menu-add-item map nil `["Accept for Session"
+                                    (jit-spell-accept-word ,word 'session)])
+      (unless menu (popup-menu map)))
+    menu))
+
+(defun jit-spell--unhide-overlay ()
+  "Unhide the overlay stored in `jit-spell--hidden-overlay'."
+  (pcase jit-spell--hidden-overlay
+    (`(,timer ,ov ,face)
+     (cancel-timer timer)
+     (overlay-put ov 'face face)
+     (setq jit-spell--hidden-overlay nil))))
+
+(defun jit-spell--unfontify (&optional start end)
+  "Remove overlays and forget checking status from START to END (or whole 
buffer)."
+  (save-restriction
+    (widen)
+    (setq start (or start (point-min)))
+    (setq end (or end (point-max)))
+    (remove-overlays start end 'category 'jit-spell)
+    (remove-list-of-text-properties start end '(jit-spell-pending))))
+
+;;; Subprocess communication
+
+(defun jit-spell--process-parameters ()
+  "Return a list of parameters for this buffer's ispell process.
+Buffers where this produces `equal' results will share their
+ispell process."
+  (list ispell-program-name
+        ispell-current-dictionary
+        ispell-current-personal-dictionary
+        ispell-extra-args))
+
+(defun jit-spell--get-process ()
+  "Get an ispell process for the current buffer."
+  (let* ((params (jit-spell--process-parameters))
+         (proc (plist-get jit-spell--process-pool params #'equal)))
+    (if (process-live-p proc)
+        proc
+      (unless ispell-async-processp
+        (error "`jit-spell-mode' requires `ispell-async-processp'"))
+      (ispell-set-spellchecker-params)
+      (setq proc (ispell-start-process))
+      (set-process-query-on-exit-flag proc nil)
+      (setq jit-spell--process-pool
+            (plist-put jit-spell--process-pool params proc #'equal))
+      (set-process-filter proc #'jit-spell--process-filter)
+      (set-process-buffer proc (generate-new-buffer " *jit-spell*"))
+      (process-send-string proc "!\n")  ;Enter terse mode
+      proc)))
+
+(defun jit-spell--process-filter (proc string)
+  "Filter function for jit-spell processes."
+  (with-current-buffer (process-buffer proc)
+    (save-excursion
+      (goto-char (point-max))
+      (insert string))
+    (when (re-search-forward "^\n" nil t) ;TODO: Process in chunks
+      (pcase-let ((`(,buffer ,tick ,start ,end)
+                   (process-get proc 'jit-spell--current-request)))
+        (process-put proc 'jit-spell--current-request nil)
+        (cond ((not (buffer-live-p buffer)))
+              ((not (eq tick (buffer-chars-modified-tick buffer)))
+               ;; Got a belated response, so schedule a retry
+               (jit-spell--schedule-pending-checks buffer))
+              (t ;; Response is good, apply misspelling overlays
+               (let (misspellings)
+                 (goto-char (point-min))
+                 (while (re-search-forward
+                         (rx bol
+                             (or (seq (any "&?")
+                                      " " (group-n 1 (+ (not " ")))
+                                      " " (+ digit)
+                                      " " (group-n 2 (+ digit))
+                                      ":")
+                                 (seq "#"
+                                      " " (group-n 1 (+ (not " ")))
+                                      " " (group-n 2 (+ digit)))))
+                         nil t)
+                   (let ((word (match-string 1))
+                         (start (string-to-number (match-string 2)))
+                         corrections)
+                     (goto-char (match-end 0))
+                     (while (re-search-forward (rx (+ (not (any ", \n"))))
+                                               (pos-eol) t)
+                       (push (match-string 0) corrections))
+                     (push (list word start (nreverse corrections))
+                           misspellings)))
+                 (jit-spell--make-overlays buffer start end misspellings))))
+        (delete-region (point-min) (point-max))
+        ;; Send next request to ispell process, if applicable
+        (let (request)
+          (while (and (setq request (pop (process-get proc 
'jit-spell--requests)))
+                      (pcase-let ((`(,buffer ,tick) request))
+                        (not (and (buffer-live-p buffer)
+                                  (eq tick (buffer-chars-modified-tick 
buffer))))))
+            (when (buffer-live-p buffer)
+              (jit-spell--schedule-pending-checks (car request))))
+          (when request
+            (jit-spell--send-request proc request)))))))
+
+(defun jit-spell--send-request (proc request)
+  "Send REQUEST to ispell process PROC."
+  (process-put proc 'jit-spell--current-request request)
+  (pcase-let ((`(,buffer _ ,start ,end) request))
+    (with-current-buffer buffer
+      (let ((text (buffer-substring-no-properties start end)))
+                                        ;TODO: allow custom
+                                        ;buffer-substring functions
+                                        ;e.g. to work around
+                                        ;hunspell's apostrophe issue.
+        ;; Redact control characters in text
+        (dotimes (i (length text))
+          (when (< (aref text i) ?\s)
+            (aset text i ?\s)))
+        (process-send-string proc "^")
+        (process-send-string proc text)
+        (process-send-string proc "\n")))))
+
+(defun jit-spell--schedule-pending-checks (buffer)
+  "Schedule a call to `jit-spell--check-pending-regions' in BUFFER."
+  (when (buffer-live-p buffer)
+    (when (bound-and-true-p jit-spell--debug)
+      (message "Scheduled recheck"))
+    (with-current-buffer buffer
+      (when (timerp jit-spell--recheck-timer)
+        (cancel-timer jit-spell--recheck-timer))
+      (setq jit-spell--recheck-timer
+            (run-with-idle-timer 0.1 nil #'jit-spell--check-pending-regions 
buffer)))))
+
+(defun jit-spell--check-pending-regions (buffer)
+  "Enqueue spell check requests for all pending regions of BUFFER."
+  (when (buffer-live-p buffer)
+    (with-current-buffer buffer         ;TODO: Need to widen?
+      (let ((proc (jit-spell--get-process))
+            (tick (buffer-chars-modified-tick))
+            (end (point-min))
+            (limit (point-max))
+            start)
+        (while (setq start (text-property-any end limit 'jit-spell-pending t))
+          (setq end (or (text-property-not-all start limit 'jit-spell-pending 
t)
+                        limit))
+          (push (list buffer tick start end) (process-get proc 
'jit-spell--requests)))
+        (when-let ((request (and (not (process-get proc 
'jit-spell--current-request))
+                                 (pop (process-get proc 
'jit-spell--requests)))))
+          (jit-spell--send-request proc request))))))
+
+(defun jit-spell--check-region (start end)
+  "Enqueue a spell check request for region between START and END.
+This is intended to be a member of `jit-lock-functions'."
+  (save-excursion ;; Extend region to include whole words
+    (goto-char start)
+    (setq start (if (re-search-backward "\\s-" nil t) (match-end 0) 
(point-min)))
+    (goto-char end)
+    (setq end (or (re-search-forward "\\s-" nil t) (point-max))))
+  (put-text-property start end 'jit-spell-pending t)
+  (let ((proc (jit-spell--get-process))
+        (request (list (current-buffer) (buffer-chars-modified-tick) start 
end)))
+    (if (process-get proc 'jit-spell--current-request)
+        (push request (process-get proc 'jit-spell--requests))
+      (jit-spell--send-request proc request)))
+  `(jit-lock-bounds ,start . ,end))
+
+;;; Interactive commands and major mode
+
+(defun jit-spell-accept-word (word where)
+  "Accept spelling of WORD.
+WHERE can be `dict' (save in personal dictionary), `buffer' (save
+as a local word at the end of the buffer), `session' (accept
+temporarily and in this buffer only), or `query' (ask for one of
+the above)."
+  (interactive
+   (list (completing-read
+          "Add word: "
+          (thread-last
+            (overlays-in (window-start) (window-end))
+            (seq-sort (lambda (o1 o2) (< (overlay-start o1)
+                                         (overlay-start o2))))
+            (seq-keep (lambda (ov)
+                        (when (eq (overlay-get ov 'category) 'jit-spell)
+                          (buffer-substring-no-properties
+                           (overlay-start ov) (overlay-end ov))))))
+          nil nil nil nil
+          (thing-at-point 'word))
+         'query))
+  (pcase-exhaustive where
+    ('session (when ispell-buffer-local-name
+                (setq ispell-buffer-local-name (buffer-name)))
+              (cl-pushnew word ispell-buffer-session-localwords
+                          :test #'equal))
+    ('buffer (ispell-add-per-file-word-list word)
+             (jit-spell-accept-word word 'session))
+    ('dict (process-send-string (jit-spell--get-process)
+                                (format "*%s\n#\n" word)))
+    ('query (jit-spell-accept-word
+             word
+             (pcase (read-multiple-choice (substitute-quotes
+                                           (format "Add `%s' to" word))
+                                          '((?d "dictionary")
+                                            (?b "buffer")
+                                            (?s "session"))) ;TODO: help string
+               (`(?d ,_) 'dict)
+               (`(?b ,_) 'buffer)
+               (`(?s ,_) 'session)))))
+  (jit-lock-refontify))
+
+(defun jit-spell-correct-word (arg)
+  "Correct a misspelled word in the selected window.
+With a numeric ARG, skip over that many misspellings.
+
+You can also accept the spelling in question by entering `@' in
+the prompt.  It is possible to modify the spelling to be
+accepted, say change capitalization or inflection, by entering
+any text after the `@'."
+  (interactive "p")
+  (let* ((ov (or (jit-spell--search-overlay (point) (- arg))
+                 (user-error "No misspellings")))
+         (start (overlay-start ov))
+         (end (overlay-end ov))
+         (word (buffer-substring-no-properties start end))
+         (highlight (make-overlay start end)))
+    (unwind-protect
+        (progn
+          (overlay-put highlight 'face 'highlight)
+          (let* ((corr (completing-read
+                        (format-prompt "Correct `%s' (enter `@' to accept)" 
nil word)
+                        (append (overlay-get ov 'jit-spell-corrections)
+                                (list (concat "@" word)))
+                        nil nil nil nil nil t)))
+            (if (string-match "\\`@\\s-*\\(.+\\)?" corr)
+                (jit-spell-accept-word (or (match-string 1 corr) word) 'query)
+              (jit-spell--apply-correction ov corr))))
+      (delete-overlay highlight))))
+
+(defalias 'jit-spell-change-dictionary 'ispell-change-dictionary) ;For 
discoverability
+
+(defun jit-spell--read-local-words ()
+  "Look for local words in the buffer and accept them for this session."
+  (save-excursion
+    (goto-char (point-min))
+    (while (search-forward ispell-words-keyword nil t)
+      (let ((limit (pos-eol)))
+       (while (re-search-forward "\\s-*\\(\\S-+\\)" limit t)
+          (jit-spell-accept-word (match-string-no-properties 1) 'session))))))
+
+(defun jit-spell--pre-command-hook ()
+  "Pre-command hook for `jit-spell-mode'."
+  (when (and jit-spell--hidden-overlay
+             (not (memq this-command jit-spell-delayed-commands)))
+    (jit-spell--unhide-overlay)))
+
+(defvar-keymap jit-spell-mode-map
+  :doc "Keymap for `jit-spell-mode'."
+  "C-;" #'jit-spell-correct-word
+  "C-:" #'jit-spell-accept-word)
+
+;;;###autoload
+(define-minor-mode jit-spell-mode
+  "Just-in-time spell checking."
+  :keymap jit-spell-mode-map
+  :lighter (" Spell"
+            (:propertize
+             (:eval
+             (concat "/" (let ((s (or ispell-local-dictionary
+                                      ispell-dictionary
+                                       "--")))
+                            (substring s 0 (string-search "_" s)))))
+             help-echo "mouse-1: Change dictionary"
+             local-map (keymap
+                        (mode-line keymap
+                                   (mouse-1 . ispell-change-dictionary)))))
+  (cond
+   (jit-spell-mode
+    (cond
+     ((derived-mode-p 'prog-mode)
+      (add-function :before-until (local 'jit-spell-ignored-p)
+                    #'jit-spell--prog-ignored-p))
+     ((derived-mode-p 'org-mode)
+      (setq-local jit-spell-delayed-commands
+                  (append '(org-delete-backward-char org-self-insert-command)
+                          jit-spell-delayed-commands))))
+    (jit-spell--read-local-words)
+    (add-hook 'ispell-change-dictionary-hook 'jit-spell--unfontify nil t)
+    (add-hook 'context-menu-functions 'jit-spell--context-menu nil t)
+    (add-hook 'pre-command-hook #'jit-spell--pre-command-hook nil t)
+    (jit-lock-register #'jit-spell--check-region))
+   (t
+    (jit-lock-unregister #'jit-spell--check-region)
+    (remove-hook 'pre-command-hook #'jit-spell--pre-command-hook)
+    (remove-hook 'context-menu-functions 'jit-spell--context-menu t)
+    (remove-hook 'ispell-change-dictionary-hook 'jit-spell--unfontify t)
+    (kill-local-variable 'ispell-buffer-session-localwords)
+    (kill-local-variable 'jit-spell-delayed-commands)
+    (kill-local-variable 'jit-spell-ignored-p)))
+  (jit-spell--unfontify))
+
+;; Don't litter M-x
+(put 'jit-spell--context-menu 'completion-predicate #'ignore)
+(dolist (sym '(jit-spell-change-dictionary
+               jit-spell-correct-word
+               jit-spell-accept-word))
+  (put sym 'completion-predicate (lambda (&rest _) jit-spell-mode)))
+
+(provide 'jit-spell)
+
+;;; jit-spell.el ends here



reply via email to

[Prev in Thread] Current Thread [Next in Thread]