;;; electric-ospl.el --- Electric OSPL Mode -*- lexical-binding: t -*-

;; Author: Samuel W. Flint <swflint@flintfam.org>
;; Package-Version: 3.3.2
;; Package-Revision: 88ab5ca588b4
;; Package-Requires: ((emacs "26.1"))
;; Keywords: convenience, text
;; URL: https://git.sr.ht/~swflint/electric-ospl-mode
;; SPDX-License-Identifier: GPL-3.0-or-later
;; SPDX-FileCopyrightText: 2023 Samuel W. Flint <swflint@flintfam.org>

;; This file is not part of GNU Emacs.

;; 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:
;;
;; A simple minor-mode to automatically enforce a "one-sentence per
;; line" style in text files.
;;
;;;; Installation
;;
;; Place the file `electric-ospl.el' somewhere on your `load-path',
;; and `require` it (note, autoloading is also supported).  Enable
;; `electric-ospl-mode' anywhere where you want to follow a
;; "one-sentence per line" style, for instance, by adding to various
;; mode hooks (I use `text-mode-hook' personally).
;;
;; Additionally, a globalized minor mode, `global-electric-ospl-mode'
;; is available, which will activate the mode based on the value of
;; `electric-ospl-global-modes' (see below).  Moreover,
;; `global-electric-ospl-mode' can be disabled by setting the
;; buffer-local `electric-ospl-forbid' variable to non-nil.
;;
;;;; Configuration
;;
;; There are a couple of options which can be used to modify behavior
;; (and speed) of the mode.  The variables noted with (CUSTOM) *must*
;; be customized using either the customize interface or (on Emacs
;; 29.1 or later) `setopt', because a setter is used to cache the
;; regular expressions.
;;
;; - The first is `electric-ospl-regexps' (CUSTOM), which sets the
;;   list of regular expressions defining how a sentence ends.
;;
;; - The next is `electric-ospl-ignored-abbreviations' (CUSTOM), which
;;   is a (case-sensitive) list of abbrevations ending in a period
;;   that are not necessarily considered the end of a sentence.
;;
;; - Next, efficiency may be modified by changing
;;   `electric-ospl-maximum-lookback-chars', which determines how far
;;   to look back to find the end of a sentence.
;;
;;  - Additionally, the `electric-ospl-sentence-end-functions' hook
;;    determines if point is currently at the end of a sentence.  It
;;    defaults to using the regexp returned by `sentence-end' but may
;;    be extended to use sentence-end determination logic provided by
;;    other packages.
;;
;;  - Finally, you can configure ignoring of OSPL in certain
;;    circumstances using the
;;    `electric-ospl-ignore-electric-functions' hook.  This variable
;;    defaults to ignoring when the last thing was in the ignored
;;    abbreviations list.  It is used by checking, one-by-one for a
;;    function which returns non-nil.  An example use is below.
;;
;; (add-hook 'LaTeX-mode-hook (defun my/latex-ospl-config ()
;;                              (add-hook 'electric-ospl-ignore-electric-functions
;;                                        (defun my/ignore-ospl-in-some-latex-envs ()
;;                                          (cl-member (LaTeX-current-environment)
;;                                                     '("tabular" "tabularx" "tikzpicture")))
;;                                        -100 t)))
;;
;; Additionally, where the globalized mode is enabled is configured
;; using `electric-ospl-global-modes', which has the following
;; semantics.
;;
;; - If t, it will be enabled in all modes (except for special modes,
;;   ephemeral buffers, or the minibuffer).
;;
;; - If nil, it will not be enabled in any modes.
;;
;; - If the first symbol is `not', `electric-ospl-mode' will not be
;;   enabled in buffers with the listed major modes or descending from
;;   the listed major modes.
;;
;; - Otherwise, it will be enabled only in the listed modes.
;;
;; It may also be configured using the
;; `electric-ospl-allowed-override-commands' variable, which defines
;; which bindings for SPC may be overridden.  If the current binding
;; for SPC is not in `electric-ospl-allowed-override-commands', then
;; the mode will not be activated locally.  This defaults to
;; `self-insert-command' and `org-self-insert-command'.
;;
;;;; Acknowledgments
;;
;; This code is based significantly on Jan Seeger's `twauctex'
;; (https://github.com/jeeger/twauctex).  I've made effort to simplify
;; the logic and make it so that it's usable in modes other than
;; `LaTeX-mode'.
;;
;;;; Errors and Patches
;;
;; If you find an error or have a patch to improve this package, please
;; send an email to `~swflint/emacs-utilities@lists.sr.ht`.


;;; Code:

;;; Caching Regular Expressions

(defvar electric-ospl--ignored-abbrevs-regexp ""
  "Regular-expression for of `electric-ospl-ignored-abbreviations'.

This variable is generated automatically, and upon change to the
original variable.  Do not modify it directly.")

(defvar electric-ospl--abbrev-lookback 0
  "How far should look-back be performed for ignored abbreviations?

This variable is generated automatically from
`electric-ospl-ignored-abbreviations', and upon change to the
original variable.  Do not modify it directly.")

(defvar electric-ospl--single-sentence-end-regexp ""
  "Single sentence-ending regular expression.

This variable is automatically generated from
`electric-ospl-regexps' and upon its change.  Do not modify it
directly.")


;;; Customization

(defun electric-ospl--cache-values (option new-value)
  "Properly set OPTION with NEW-VALUE.

In addition to setting the top-level value, these cache values automatically."
  (set-default-toplevel-value option new-value)
  (pcase option
    ('electric-ospl-ignored-abbreviations
     (setf electric-ospl--ignored-abbrevs-regexp (regexp-opt new-value)
           electric-ospl--abbrev-lookback (+ 2 (apply #'max
                                                      (mapcar #'length
                                                              new-value)))))
    ('electric-ospl-regexps
     (setf electric-ospl--single-sentence-end-regexp
           (concat "\\(?:" (mapconcat (lambda (regexp)
                                        (concat "\\(?:" regexp "\\)"))
                                      new-value
                                      "\\|")
                   "\\)")))))

(defgroup electric-ospl nil
  "Customization for electric-ospl.

electric-ospl is an electric SPC that helps to make
one-sentence-per-line editing slightly easier."
  :group 'text)

(defcustom electric-ospl-regexps (list (sentence-end))
  "Definitions of the end of a sentence.

This defaults to what the function `sentence-end' returns at load
time.

Note: This must be changed through the Customize interface or
using the `setopt' macro."
  :group 'electric-ospl
  :type '(repeat regexp)
  :set #'electric-ospl--cache-values
  :initialize 'custom-initialize-set)

(defcustom electric-ospl-ignored-abbreviations (list "et al."
                                                     "etc."
                                                     "i.e."
                                                     "e.g."
                                                     "vs."
                                                     "viz.")
  "A list of (case-sensitive) abbrevations which may not end sentences.

It is generally a good idea to customize this based on your
writing and those which you frequently use.

Note: This must be changed through the Customize interface or
using the `setopt' macro."
  :group 'electric-ospl
  :type '(repeat (string :tag "Abbreviation"))
  :set #'electric-ospl--cache-values
  :initialize 'custom-initialize-set)

(defcustom electric-ospl-maximum-lookback-chars 1
  "How far is lookback performed to find a sentence ending.

This should be calculated from the longest possible match to
`electric-ospl-regexps'."
  :group 'electric-ospl
  :type 'integer)

(defcustom electric-ospl-ignore-electric-functions
  (list #'electric-ospl-at-indented-newline-p #'electric-ospl-at-abbrev-p)
  "Functions which are used to prevent insertion of an electric OSPL space.

A function in this hook should return t if an electric space
should not be inserted.  They are run one at a time, until one of
these functions return non-nil.  Additionally, these should not
modify match data or point/mark.

It may be useful to set this as a buffer-local hook in some
modes (`LaTeX-mode' for instance, to not do OSPL in certain
environments).

For information about the defaults, see
`electric-ospl-at-indentend-newline-p' and
`electric-ospl-at-abbrev-p'."
  :group 'electric-ospl
  :type 'hook)

(defcustom electric-ospl-sentence-end-functions
  (list #'electric-ospl-at-sentence-end-p)
  "Functions which decide if point is currently at sentence end.

A function in this hook should return t if point is currently at
the end of a sentence.  They are run one at a time, until one
returns non-nil.  Additionally, these should not modify match
data or point/mark.

It may be useful to set this as a buffer-local hook in some modes
where sentence end detection is not clear.

See `electric-ospl-at-sentence-end-p' for information about
defaults."
  :group 'electric-ospl
  :type 'hook)

(defcustom electric-ospl-global-modes t
  "Modes in which `global-electric-ospl-mode' should enable the local mode.

If t, `electric-ospl-mode' is turned on for all major modes.  If
a list, it is turned on for all `major-mode' symbols; however, if
the `car' is `not', the mode is turned on for all modes not in
that list (or not descending from that list).  If nil,
`electric-ospl-mode' is never turned on automatically.

The mode is never turned on for modes which are considered
special or are ephemeral (have a space as prefix of the name)."
  :group 'electric-ospl
  :type '(choice (const t :tag "All modes")
                 (const nil :tag "No modes")
                 (set :menu-tag "certain modes" :tag "modes"
                      :value (not)
                      (const :tag "Forbid" not)
                      (repeat :inline t (symbol :tag "mode")))))

(defvar-local electric-ospl-forbid nil
  "Set to non-nil to prevent electric-ospl from being enabled.")

(defcustom electric-ospl-allowed-override-commands
  (list #'self-insert-command
        'org-self-insert-command)
  "List of bindings which `electric-ospl' is allowed to override.

By default this will include only `org-self-insert-command' and
`self-insert-command', but depending on your configuration,
others may be appropriate as well."
  :group 'electric-ospl
  :type 'hook)

(defcustom electric-ospl-enable-fill-paragraph t
  "Enable setting `fill-paragraph-function'."
  :group 'electric-ospl
  :type 'boolean
  :local t
  :safe #'booleanp)


;;; At Abbreviation?

(defun electric-ospl-at-abbrev-p ()
  "Are we currently at a non-sentence-ending abbreviation?

This is configured using `electric-ospl-ignored-abbreviations', a
list of abbreviations which end in a sentence-ending character."
  (save-excursion
    (save-match-data
      (looking-back (rx word-start (regexp electric-ospl--ignored-abbrevs-regexp) (? (syntax whitespace)))
                    (- (point) electric-ospl--abbrev-lookback)))))


;;; At indented newline?

(defun electric-ospl-at-indented-newline-p ()
  "Are we currently at an (indented) newline?

While `bolp' exists to determine if point is currently at the
beginning of a line, it does not consider if the line is
indented (as may be seen in a number of markup languages).  This
checks for both."
  (save-excursion
    (save-match-data
      (looking-back (rx bol (* blank)) nil))))


;;; At sentence end?

(defun electric-ospl-at-sentence-end-p ()
  "Are we currently at the end of a sentence?

This is determined by checking if the point comes after a member
of `electric-ospl-regexps' (when combined as
`electric-ospl--single-sentence-end-regexp'), using the maximum
lookback of `electric-ospl-maximum-lookback-chars'."
  (save-excursion
    (save-match-data
      (looking-back electric-ospl--single-sentence-end-regexp
                    electric-ospl-maximum-lookback-chars))))


;;; Electric Space

(defvar-local electric-ospl-original-binding #'self-insert-command
  "What was SPC originally bound to?

This is called by `electric-ospl-electric-space' so that if a
major mode does things a bit cleverly, it should work.")

(defun electric-ospl-electric-space (arg)
  "Insert a space character, or an OSPL line break as appropriate.

If ARG is given, insert a space, do not break line.  If the
command is repeated, delete the line-break."
  (interactive "p")
  (if (run-hook-with-args-until-success 'electric-ospl-ignore-electric-functions)
      (funcall-interactively electric-ospl-original-binding arg)
    (self-insert-command 1)               ; Fool the sentence-end regular expressions...
    (let* ((case-fold-search nil)
           (repeated-p (or (> arg 1)
                           (eq last-command 'electric-ospl-electric-space)))
           (at-electric-p (run-hook-with-args-until-success 'electric-ospl-sentence-end-functions)))
      (delete-char -1)                    ; Character is probably no longer needed
      (cond
       ((and repeated-p (bolp))
        (delete-char -1)
        (funcall-interactively electric-ospl-original-binding 1))
       (at-electric-p
        (newline)
        (indent-according-to-mode))
       (t (funcall-interactively electric-ospl-original-binding arg))))))


;;; Refill as OSPL

(defvar-local electric-ospl-original-fill-paragraph nil
  "What was `fill-paragraph-function' originally?

This is called by `electric-ospl-fill-ospl' so that, if a major
mode or similar does things cleverly, \\[universal-argument]
\\[electric-ospl-fill-ospl] works correctly.")

(defun electric-ospl-fill-ospl (&optional arg)
  "Fill paragraph into one-sentence-per-line format.

If ARG is passed, call the regular `fill-paragraph' instead."
  (interactive "P")
  (unless (and (bolp) (eolp))
    (if arg
        (funcall-interactively electric-ospl-original-fill-paragraph)
      (save-mark-and-excursion
        (save-match-data
          (let ((fill-column most-positive-fixnum)
                (sentence-end electric-ospl--single-sentence-end-regexp))
            (fill-paragraph)
            (let ((end-boundary (save-excursion (forward-paragraph 1)
                                                (backward-sentence)
                                                (point-marker))))
              (beginning-of-line)
              (while (progn (forward-sentence)
                            (<= (point) (marker-position end-boundary)))
                (unless (electric-ospl-at-abbrev-p)
                  (just-one-space)
                  (delete-char -1)
                  (newline)
                  (indent-according-to-mode)))))))
      t)))


;;; Global Minor Mode Safety

(defun electric-ospl-allowed-mode-p ()
  "Should `electric-ospl-mode' be enabled in the current buffer?

This is used to determine if `electric-ospl-mode' applies to a buffer
when `global-electric-ospl-mode' is enabled.  The following conditions
*prevent* `electric-ospl-mode' from being enabled:

 - Buffers where `electric-ospl-forbid' is non-nil
 - Buffers whose mode is derived from `special-mode'
 - `fundamental-mode'
 - Ephemeral Buffers (see Info node `(elisp)Buffer Names')
 - Minibuffers
 - Buffers where the binding of SPC is not in
   `electric-ospl-allowed-override-commands'
 - Major modes excluded by `electric-ospl-global-modes' (which see)."
  (and (not electric-ospl-forbid)
       (not (derived-mode-p 'special-mode))
       (not (eq major-mode 'fundamental-mode))
       (not (eq (aref (buffer-name (current-buffer)) 0) ?\s))
       (not (minibufferp))
       (cl-member (key-binding (kbd "SPC")) electric-ospl-allowed-override-commands)
       (pcase electric-ospl-global-modes
         (`t t)
         (`(not . ,modes) (and (not (memq major-mode modes))
                               (not (apply #'derived-mode-p modes))))
         (modes (memq major-mode modes)))))

(defun electric-ospl-enable-mode ()
  "Conditionally enable `electric-ospl-mode' in the current buffer.

`electric-ospl-mode' will only be enabled when
`electric-ospl-allowed-mode-p' is non-nil."
  (when (electric-ospl-allowed-mode-p)
    (electric-ospl-mode)))


;;; Minor Mode Definition

(defvar electric-ospl-mode-map
  (let ((keymap (make-keymap)))
    (define-key keymap (kbd "SPC") #'electric-ospl-electric-space)
    keymap)
  "Keymap for `electric-ospl-mode'.")

;;;###autoload
(define-minor-mode electric-ospl-mode
  "A basic One-Sentence-Per-Line mode which defines an electric SPC key."
  :lighter " OSPL" :keymap electric-ospl-mode-map
  (cond
   ((and electric-ospl-mode
         electric-ospl-enable-fill-paragraph)
    (setq-local electric-ospl-original-binding (let ((electric-ospl-mode nil))
                                                 (key-binding (kbd "SPC")))
                electric-ospl-original-fill-paragraph fill-paragraph-function
                fill-paragraph-function #'electric-ospl-fill-ospl))
   (electric-ospl-mode
    (setq-local electric-ospl-original-binding (let ((electric-ospl-mode nil))
                                                 (key-binding (kbd "SPC")))))
   ((and (not electric-ospl-mode)
         electric-ospl-original-fill-paragraph)
    (setq-local fill-paragraph-function electric-ospl-original-fill-paragraph))))

;;;###autoload
(define-globalized-minor-mode global-electric-ospl-mode electric-ospl-mode
  electric-ospl-enable-mode
  :init-value nil)


(provide 'electric-ospl)

;;; electric-ospl.el ends here
